Files
pn-new-crm/src/components/interests/interest-contact-log-tab.tsx
Matt e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00

631 lines
23 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
CalendarDays,
Mail,
Mic,
MicOff,
MoreVertical,
Phone,
Pencil,
Plus,
Trash2,
Users,
Video,
} from 'lucide-react';
import { useVoiceTranscription } from '@/hooks/use-voice-transcription';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
// §2.1: contact-log compose surface migrated from Dialog to Sheet so it
// matches the side-panel doctrine used by every other compose surface in
// the app (ClientForm, InterestForm, YachtForm, EOI Generate). The
// dialog name `ComposeDialog` is kept for git-blame continuity but the
// component now renders <Sheet side="right">.
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DateTimePicker } from '@/components/ui/date-time-picker';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { cn } from '@/lib/utils';
interface InterestContactLogTabProps {
interestId: string;
}
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
type Direction = 'outbound' | 'inbound';
type Template = 'call' | 'visit' | 'email';
interface ContactLogEntry {
id: string;
occurredAt: string;
channel: Channel;
direction: Direction;
summary: string;
voiceTranscript: string | null;
templateUsed: string | null;
followUpAt: string | null;
reminderId: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/** Quick-template seeds - drop a starting structure into the summary so reps
* spend their typing on the substance, not the scaffolding. */
const TEMPLATE_SEEDS: Record<
Template,
{ channel: Channel; direction: Direction; summary: string; label: string; icon: ChannelIcon }
> = {
call: {
channel: 'phone',
direction: 'outbound',
summary: 'Called the client. Discussed:\n\n• \n\nNext step: ',
label: 'Call',
icon: Phone,
},
visit: {
channel: 'in_person',
direction: 'outbound',
summary: 'Met with the client in person. Discussed:\n\n• \n\nNext step: ',
label: 'Visit',
icon: Users,
},
email: {
channel: 'email',
direction: 'outbound',
summary: 'Emailed the client.\n\nTopic: \n\nResponse expected: ',
label: 'Email',
icon: Mail,
},
};
type ChannelIcon = React.ComponentType<{ className?: string }>;
const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: string }> = {
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: WhatsAppIcon, tone: 'bg-emerald-100 text-emerald-700' },
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
};
/**
* Per-interaction contact log. Sales reps log every email / call /
* WhatsApp / meeting touch with the client here so the team has a
* structured history of "what was the last conversation about" - not
* just the bare "last contact 8d ago" timestamp on the interest.
*
* Each entry can optionally schedule a follow-up that auto-creates a
* reminder pointing back at the interest. Editing the entry's
* follow-up date keeps the linked reminder in sync; deleting the
* entry removes the reminder.
*/
export function InterestContactLogTab({ interestId }: InterestContactLogTabProps) {
const [composeOpen, setComposeOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ContactLogEntry | null>(null);
const { data: res, isLoading } = useQuery<{ data: ContactLogEntry[] }>({
queryKey: ['interests', interestId, 'contact-log'],
queryFn: () =>
apiFetch<{ data: ContactLogEntry[] }>(`/api/v1/interests/${interestId}/contact-log`),
});
const entries = res?.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-foreground">Contact log</h3>
<p className="text-xs text-muted-foreground">
Record each conversation. The most recent log entry sets the &ldquo;Last contact&rdquo;
chip on the interest header.
</p>
</div>
<Button size="sm" onClick={() => setComposeOpen(true)} className="gap-1.5">
<Plus className="size-4" aria-hidden />
Log contact
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 rounded-lg" aria-hidden />
<Skeleton className="h-20 rounded-lg" aria-hidden />
</div>
) : entries.length === 0 ? (
<EmptyState onAdd={() => setComposeOpen(true)} />
) : (
<ol className="space-y-2">
{entries.map((e) => (
<ContactLogRow key={e.id} entry={e} interestId={interestId} onEdit={setEditTarget} />
))}
</ol>
)}
<ComposeDialog interestId={interestId} open={composeOpen} onOpenChange={setComposeOpen} />
{editTarget && (
<ComposeDialog
interestId={interestId}
existing={editTarget}
open={!!editTarget}
onOpenChange={(o) => !o && setEditTarget(null)}
/>
)}
</div>
);
}
// ─── Row ─────────────────────────────────────────────────────────────────────
function ContactLogRow({
entry,
interestId,
onEdit,
}: {
entry: ContactLogEntry;
interestId: string;
onEdit: (e: ContactLogEntry) => void;
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const channelMeta = CHANNEL_META[entry.channel];
const Icon = channelMeta.icon;
const deleteMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/contact-log/${entry.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success('Contact log entry deleted.');
},
onError: (err) => toastError(err),
});
return (
<li className="rounded-lg border bg-background p-3">
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full',
channelMeta.tone,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{channelMeta.label}</span>
<Badge variant="outline" className="text-xs capitalize">
{entry.direction}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(entry.occurredAt), 'MMM d, yyyy · HH:mm')}
</span>
<span className="text-xs text-muted-foreground">
({formatDistanceToNowStrict(new Date(entry.occurredAt))} ago)
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap">{entry.summary}</p>
{entry.followUpAt && (
<p className="inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs text-amber-900">
<Bell className="size-3" aria-hidden />
Follow up {format(new Date(entry.followUpAt), 'MMM d, yyyy')} (reminder created)
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Row actions">
<MoreVertical className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(entry)}>
<Pencil className="mr-2 size-3.5" aria-hidden />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
disabled={deleteMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Delete contact log entry',
description: 'This cannot be undone.',
confirmLabel: 'Delete',
});
if (ok) deleteMutation.mutate();
}}
>
<Trash2 className="mr-2 size-3.5" aria-hidden />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{confirmDialog}
</li>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground">
<Phone className="size-5" aria-hidden />
</div>
<h3 className="mt-3 text-sm font-medium text-foreground">No contact logged yet</h3>
<p className="mt-1 text-xs text-muted-foreground">
Record every call, email, and meeting so the team has full context the next time someone
picks up the deal.
</p>
<Button size="sm" onClick={onAdd} className="mt-4 gap-1.5">
<Plus className="size-3.5" aria-hidden />
Log first contact
</Button>
</div>
);
}
// ─── Compose / edit sheet ───────────────────────────────────────────────────
// Exported for §1.4 - interest-detail-header.tsx mounts this sheet
// directly via a "Log contact" quick-action button (sibling to the
// Email / Call / WhatsApp pills) so the rep doesn't have to navigate
// to the Contact log tab first.
export function ComposeDialog(props: {
interestId: string;
existing?: ContactLogEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
// Key-based remount: body keyed on open + existing.id so useState
// initializers re-run each time the dialog opens with a new row.
// Replaces the prior useEffect(setState, [open, existing]) sync.
return (
<ComposeDialogBody
key={props.open ? `open:${props.existing?.id ?? 'new'}` : 'closed'}
{...props}
/>
);
}
function ComposeDialogBody({
interestId,
existing,
open,
onOpenChange,
}: {
interestId: string;
existing?: ContactLogEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const isEdit = !!existing;
const defaultOccurredAt = useMemo(() => {
if (existing) return localIsoString(existing.occurredAt);
return localIsoString(new Date().toISOString());
}, [existing]);
const [occurredAt, setOccurredAt] = useState<string>(defaultOccurredAt);
const [channel, setChannel] = useState<Channel>(existing?.channel ?? 'phone');
const [direction, setDirection] = useState<Direction>(existing?.direction ?? 'outbound');
const [summary, setSummary] = useState<string>(existing?.summary ?? '');
const [followUpAt, setFollowUpAt] = useState<string>(
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
);
const [templateUsed, setTemplateUsed] = useState<Template | null>(
(existing?.templateUsed as Template | undefined) ?? null,
);
// Voice transcript is captured separately so an edit to summary doesn't
// overwrite the rep's original raw utterance. Preserved on the row.
const [voiceTranscript, setVoiceTranscript] = useState<string>(existing?.voiceTranscript ?? '');
const voice = useVoiceTranscription();
// Append committed transcript chunks into the summary as the rep speaks.
// We diff against the previous final transcript so we only append the new
// tail - otherwise the entire transcript gets re-pasted on every event.
const previousFinalRef = useRef<string>('');
useEffect(() => {
const prev = previousFinalRef.current;
if (voice.transcript === prev) return;
const added = voice.transcript.slice(prev.length).trim();
if (added.length === 0) {
previousFinalRef.current = voice.transcript;
return;
}
setSummary((prevSummary) => {
const sep =
prevSummary && !prevSummary.endsWith(' ') && !prevSummary.endsWith('\n') ? ' ' : '';
return prevSummary + sep + added;
});
setVoiceTranscript((prev2) => (prev2 ? `${prev2} ${added}` : added));
previousFinalRef.current = voice.transcript;
}, [voice.transcript]);
function applyTemplate(t: Template) {
const seed = TEMPLATE_SEEDS[t];
setChannel(seed.channel);
setDirection(seed.direction);
// Don't clobber if the rep already typed something - append a divider
// so the template scaffolds the *next* block.
setSummary((cur) => (cur.trim().length === 0 ? seed.summary : `${cur}\n\n${seed.summary}`));
setTemplateUsed(t);
}
const mutation = useMutation({
mutationFn: async () => {
const body = {
occurredAt: new Date(occurredAt).toISOString(),
channel,
direction,
summary,
voiceTranscript: voiceTranscript.trim().length > 0 ? voiceTranscript : null,
templateUsed,
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
};
if (isEdit) {
return apiFetch(`/api/v1/contact-log/${existing!.id}`, {
method: 'PATCH',
body,
});
}
return apiFetch(`/api/v1/interests/${interestId}/contact-log`, {
method: 'POST',
body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
// Bump the parent interest cache so the "Last contact" header chip
// updates without a refresh.
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success(isEdit ? 'Contact log entry updated.' : 'Contact logged.');
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-3/4 sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</SheetTitle>
<SheetDescription>
Record the channel, the direction, and what was discussed. Optionally schedule a
follow-up - a reminder will be created automatically.
</SheetDescription>
</SheetHeader>
<div className="space-y-3 py-1">
{/* Quick-template buttons. Tap one to seed the channel + direction
+ a starter summary so the rep can focus on the substance.
Hidden when editing - templates are a fresh-entry affordance. */}
{!isEdit ? (
<div className="flex flex-wrap gap-1.5">
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
const seed = TEMPLATE_SEEDS[t];
const Icon = seed.icon;
const active = templateUsed === t;
return (
<button
key={t}
type="button"
onClick={() => applyTemplate(t)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'border-sky-300 bg-sky-50 text-sky-800'
: 'border-border bg-muted/40 text-foreground hover:bg-muted',
)}
>
<Icon className="size-3" aria-hidden />
{seed.label}
</button>
);
})}
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="cl-channel">Channel</Label>
<Select value={channel} onValueChange={(v) => setChannel(v as Channel)}>
<SelectTrigger id="cl-channel">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CHANNEL_META) as Channel[]).map((c) => (
<SelectItem key={c} value={c}>
{CHANNEL_META[c].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="cl-direction">Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
<SelectTrigger id="cl-direction">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outbound (you reached out)</SelectItem>
<SelectItem value="inbound">Inbound (they reached out)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="cl-occurred">When did the conversation happen?</Label>
<DateTimePicker id="cl-occurred" value={occurredAt} onChange={setOccurredAt} />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label htmlFor="cl-summary">Summary</Label>
{voice.supported ? (
<button
type="button"
aria-label={
voice.isListening ? 'Stop voice transcription' : 'Start voice transcription'
}
onClick={() => (voice.isListening ? voice.stop() : voice.start())}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors',
voice.isListening
? 'border-rose-300 bg-rose-50 text-rose-800 animate-pulse'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted',
)}
>
{voice.isListening ? (
<>
<Mic className="size-3" aria-hidden />
Recording
</>
) : (
<>
<MicOff className="size-3" aria-hidden />
Voice
</>
)}
</button>
) : (
<span
title="Voice transcription isn't supported in this browser."
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
>
<MicOff className="size-3" aria-hidden />
Voice unavailable
</span>
)}
</div>
<Textarea
id="cl-summary"
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
rows={4}
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
{voice.isListening && voice.interim ? (
<p className="text-xs italic text-muted-foreground">{voice.interim}</p>
) : null}
{voice.error ? (
<p className="text-xs text-rose-700">Voice error: {voice.error}</p>
) : null}
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<label
className="flex items-center gap-2 text-sm font-medium cursor-pointer select-none"
htmlFor="cl-followup-toggle"
>
<Checkbox
id="cl-followup-toggle"
checked={!!followUpAt}
onCheckedChange={(v) => {
if (v) {
// Default to a week from now @ 09:00 local so reps get a
// usable cadence without having to type a date.
const d = new Date();
d.setDate(d.getDate() + 7);
d.setHours(9, 0, 0, 0);
const tz = d.getTimezoneOffset() * 60_000;
setFollowUpAt(new Date(d.getTime() - tz).toISOString().slice(0, 16));
} else {
setFollowUpAt('');
}
}}
/>
Add follow-up reminder?
</label>
{followUpAt ? (
<div className="space-y-1 pl-6">
<Label htmlFor="cl-followup" className="text-xs text-muted-foreground">
Remind me on
</Label>
<DateTimePicker
id="cl-followup"
value={followUpAt}
onChange={setFollowUpAt}
className="max-w-xs"
/>
<p className="text-xs text-muted-foreground">
A reminder is created on this interest for the time above.
</p>
</div>
) : null}
</div>
</div>
<SheetFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || summary.trim().length === 0}
>
{mutation.isPending ? 'Saving…' : isEdit ? 'Save changes' : 'Log contact'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
/**
* Convert an ISO string into the `YYYY-MM-DDTHH:mm` format that
* `<input type="datetime-local">` expects, in the user's local
* timezone. (Browsers don't accept the trailing `Z` in this input
* type and reject anything with a timezone offset.)
*/
function localIsoString(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}