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>
631 lines
23 KiB
TypeScript
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 “Last contact”
|
|
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())}`;
|
|
}
|