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:
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -282,9 +282,10 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(clients)
|
||||
.where(eq(clients.id, interest.clientId));
|
||||
|
||||
// EOI prerequisites: surface enough of the linked client's primary contact
|
||||
// and address so the Documents tab can show the readiness checklist
|
||||
// (Required: name, email, address — Section 2 of the EOI document).
|
||||
// EOI prerequisites + interest-detail header contact actions: surface the
|
||||
// linked client's primary email/phone (and the canonical E.164 form for
|
||||
// wa.me) so the header can render Email / Call / WhatsApp buttons without
|
||||
// a second fetch, and the Documents tab can show the EOI prereq checklist.
|
||||
const [emailContact] = await db
|
||||
.select({ value: clientContacts.value })
|
||||
.from(clientContacts)
|
||||
@@ -292,6 +293,18 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [phoneContact] = await db
|
||||
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(clientContacts.clientId, interest.clientId),
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [addressRow] = await db
|
||||
.select({ id: clientAddresses.id })
|
||||
.from(clientAddresses)
|
||||
@@ -319,6 +332,8 @@ export async function getInterestById(id: string, portId: string) {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
clientPrimaryEmail: emailContact?.value ?? null,
|
||||
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
||||
clientHasAddress: !!addressRow,
|
||||
berthMooringNumber,
|
||||
tags: tagRows,
|
||||
|
||||
Reference in New Issue
Block a user