"use client"; import { useId, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { BriefcaseIcon, ExternalLinkIcon, MailIcon, PhoneIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { ContactCompanies, type CompanyRef } from "@/components/crm/contact-companies"; import { ContactLinks } from "@/components/crm/contact-links"; import { TagPicker } from "@/components/tags/tag-picker"; import type { TagValue } from "@/components/tags/tag-chip"; import { CorrespondenceCard, type CorrespondenceAccess } from "@/components/google/correspondence-card"; import { SaveIndicator } from "@/components/save-indicator"; import type { SaveState } from "@/hooks/use-autosave"; import { addActivityAction, deleteCrmContactAction, restoreRecordsAction, saveContactAction, } from "@/server/actions"; import { ACTIVITY_LABEL, STAGE_LABEL, STAGE_TONE } from "@/lib/stages"; import { linkHref } from "@/lib/social"; import { relativeDay } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; import type { ActivityType, Stage } from "@prisma/client"; /** The kinds of touch a person logs by hand. The rest are written by the system. */ const TOUCH_TYPES: ActivityType[] = [ "NOTE", "OUTREACH", "CALL", "EMAIL_SENT", "EMAIL_RECEIVED", "FOLLOW_UP", ]; export type ContactFields = { id: string; name: string; title: string; email: string; phone: string; linkedin: string; twitter: string; instagram: string; github: string; website: string; otherLinks: string[]; relationship: string; notes: string; }; /** The job this person is attached to, as much of it as a card should show. */ export type LinkedApplication = { id: string; roleTitle: string; stage: Stage; location: string; salaryRange: string; jobUrl: string; nextFollowUpAt: string | null; }; export type ContactTouch = { id: string; type: ActivityType; body: string; occurredAt: string; }; export function ContactDetail({ contact, tags, companies, companyOptions, application, touches, logos, googleAccess, }: { contact: ContactFields; /** How they are filed. Free-form, shared with everyone else's labels. */ tags: TagValue[]; /** Everywhere they represent. Empty means nobody has said yet. */ companies: CompanyRef[]; /** Every company on file, for the picker. */ companyOptions: CompanyRef[]; application: LinkedApplication | null; touches: ContactTouch[]; logos: boolean; /** Whether their Gmail and Calendar are connected, for the threads-and-meetings card. */ googleAccess: CorrespondenceAccess; }) { const [values, setValues] = useState(contact); const [tagValues, setTagValues] = useState(tags); const [state, setState] = useState("idle"); const [note, setNote] = useState(""); const [noteType, setNoteType] = useState("NOTE"); const [logging, startLogging] = useTransition(); const [, startTransition] = useTransition(); const router = useRouter(); const logTouch = () => { const body = note.trim(); if (!body) return; startLogging(async () => { try { await addActivityAction({ contactId: contact.id, type: noteType, body }); setNote(""); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not log that."); } }); }; const commit = (patch: Partial) => { const changed = Object.entries(patch).some( ([key, value]) => contact[key as keyof ContactFields] !== value, ); if (!changed) return; setState("saving"); startTransition(async () => { try { await saveContactAction(contact.id, patch); setState("saved"); router.refresh(); } catch (error) { setState("idle"); toast.error(error instanceof Error ? error.message : "Could not save that."); setValues(contact); } }); }; const set = (patch: Partial) => setValues((prev) => ({ ...prev, ...patch })); // Tags save on the tick rather than on blur: a popover has no blur a person // reads as "done". const saveTags = (next: TagValue[]) => { const previous = tagValues; setTagValues(next); setState("saving"); startTransition(async () => { try { await saveContactAction(contact.id, { tagIds: next.map((tag) => tag.id) }); setState("saved"); router.refresh(); } catch (error) { setState("idle"); toast.error(error instanceof Error ? error.message : "Could not save that."); setTagValues(previous); } }); }; const remove = () => { if ( !confirm( `Delete ${contact.name}? Their whole timeline goes with them, and you can restore all of it from the archive.`, ) ) return; startTransition(async () => { try { await deleteCrmContactAction(contact.id); toast.success(`${contact.name} moved to the archive`, { action: { label: "Undo", onClick: () => { void restoreRecordsAction("contact", [contact.id]).then(() => router.refresh()); }, }, }); router.push("/crm/contacts"); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not delete that contact."); } }); }; return (
set({ name: event.target.value })} onBlur={() => commit({ name: values.name })} className="h-auto border-0 bg-transparent px-0 text-[22px] font-semibold tracking-tight shadow-none focus-visible:ring-0" /> {/* Who they are to you, under the name: the title, then everywhere they represent as chips you can open, add to and take away. A person is a founder at one place and an advisor at another, and the research, the people and the roles at each are a click from here. */}
{values.title || "No title set"}
{values.email && ( )}
Notes