"use client"; import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; import { ContactActivity } from "./contact-activity"; import { toast } from "@/stores/toast-store"; import { exportContact } from "./contact-export"; import { printContact } from "./contact-print"; type MoreItem = | { icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void; destructive?: boolean; separator?: false; } | { separator: true }; interface ContactDetailProps { contact: ContactCard | null; onEdit: () => void; onDelete: () => void; onAddToGroup?: () => void; onDuplicate?: () => void; /** Compose an email to this contact in the app (no OS mailto handoff). */ onCompose?: () => void; isMobile?: boolean; className?: string; } function formatPhoneFeatures(features?: Record): string { if (!features) return ""; return Object.keys(features).filter(k => features[k]).join(", "); } function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } { if (typeof dateInput === "object" && dateInput !== null) { if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") { const d = new Date(dateInput.utc); if (!isNaN(d.getTime())) { return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; } return {}; } const pd = dateInput as PartialDate; return { year: pd.year, month: pd.month, day: pd.day }; } const s = String(dateInput); if (s.startsWith("--")) { const parts = s.substring(2).split("-"); return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined }; } const d = new Date(s); if (!isNaN(d.getTime())) { return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }; } return {}; } function getCompletedYears(dateInput: AnniversaryDate): number | null { const { year, month, day } = getDateParts(dateInput); if (!year) return null; const now = new Date(); let years = now.getFullYear() - year; const m = month ?? 1; const d = day ?? 1; const nowM = now.getMonth() + 1; const nowD = now.getDate(); if (nowM < m || (nowM === m && nowD < d)) years -= 1; if (years < 0) return null; return years; } function formatDate(dateInput: AnniversaryDate): string { if (typeof dateInput === 'object' && dateInput !== null) { if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { try { const d = new Date(dateInput.utc as string); if (!isNaN(d.getTime())) { return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); } } catch { /* fallback */ } return String(dateInput.utc); } const pd = dateInput as PartialDate; const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const parts: string[] = []; if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]); if (pd.day) parts.push(String(pd.day)); if (pd.year) parts.push(String(pd.year)); return parts.join(' ') || String(dateInput); } const dateStr = String(dateInput); if (dateStr.startsWith("--")) { const parts = dateStr.substring(2).split("-"); const month = parseInt(parts[0], 10); const day = parts[1] ? parseInt(parts[1], 10) : undefined; const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1]; } try { const d = new Date(dateStr); if (!isNaN(d.getTime())) { return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); } } catch { /* fallback */ } return dateStr; } export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; if (!contact) { return (

{t("detail.no_contact_selected")}

); } const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const photoUri = getContactPhotoUri(contact); const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined; const handleExport = () => { exportContact(contact); toast.success(t("export.success", { count: 1 })); }; const handlePrint = () => { printContact(contact, name); }; const moreItems: MoreItem[] = []; if (onAddToGroup) { moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup }); } if (onDuplicate) { moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate }); } moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport }); moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint }); moreItems.push({ separator: true }); moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true }); const emails = contact.emails ? Object.values(contact.emails) : []; const phones = contact.phones ? Object.values(contact.phones) : []; const orgs = contact.organizations ? Object.values(contact.organizations) : []; const addresses = contact.addresses ? Object.values(contact.addresses) : []; const notes = contact.notes ? Object.values(contact.notes) : []; const titles = contact.titles ? Object.values(contact.titles) : []; const jobTitles = titles.filter(t => t.kind !== "role"); const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : []; const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; const nicknames = contact.nicknames ? Object.values(contact.nicknames) : []; const hasNickname = nicknames.length > 0; const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[]; const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0; const hasWork = titles.length > 0 || orgs.length > 0; const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns)); const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0; return (

{name || "-"}

{hasNickname && (

“{nicknames.map(n => n.name).join(", ")}”

)} {subtitleParts.length > 0 && (

{subtitleParts.join(" · ")}

)}
{email && onCompose && ( )} {phone && ( {t("context_menu.call")} )}
{hasContactDetails && (
{emails.map((e, i) => ( ))} {phones.map((p, i) => { const features = formatPhoneFeatures(p.features); const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[]; return ( ); })} {addresses.map((a, i) => { const lines: string[] = []; if (a.full || a.fullAddress) { lines.push((a.full || a.fullAddress) as string); } else if (a.components && a.components.length > 0) { const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", "); if (joined) lines.push(joined); } else { const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country] .map(s => (typeof s === "string" ? s.trim() : "")) .filter(Boolean) as string[]; lines.push(...parts); } return (
{lines.map((line, idx) => (
{line}
))} {a.timeZone && (
{t("detail.timezone")}: {a.timeZone}
)}
); })} {onlineServices.map((svc, i) => (
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( {svc.user || svc.uri} ) : ( {svc.user || String(svc.uri ?? '')} )}
))}
)} {hasWork && (
{orgs.map((o, i) => (
{o.name} {o.units && o.units.length > 0 && ( · {o.units.map(u => u.name).join(", ")} )}
))} {titles.map((tl, i) => (
{tl.name}
))}
)} {hasPersonal && (
{anniversaries.map((ann, i) => { const years = getCompletedYears(ann.date); const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since"; return (
{formatDate(ann.date)} {years !== null && ( · {t(suffixKey, { count: years })} )}
); })} {hasGender && (
{contact.speakToAs?.grammaticalGender && ( {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })} )} {contact.speakToAs?.pronouns && (() => { const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; return firstPronoun ? ( {contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun} ) : null; })()}
)} {preferredLanguages.map((lang, i) => (
{lang.language}
))} {personalInfo.map((pi, i) => (
{pi.value}
))}
)} {keywords.length > 0 && (
{keywords.map((kw, i) => ( {kw} ))}
)} {relatedTo.length > 0 && (
{relatedTo.map(([uri, rel], i) => { const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; return (
{uri}
); })}
)} {cryptoKeys.length > 0 && (
{cryptoKeys.map((key, i) => (
{typeof key.uri === 'string' && key.uri.startsWith("http") ? ( {key.uri} ) : ( {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} )}
))}
)} {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
{contact.calendarUri && ( {contact.calendarUri} )} {contact.schedulingUri && ( {contact.schedulingUri} )} {contact.freeBusyUri && ( {contact.freeBusyUri} )}
)} {notes.length > 0 && (
{notes.map((n, i) => (

{n.note}

))}
)} {(contact.created || contact.updated) && (
{contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
}
)}
); } function formatContexts(contexts?: Record): string { if (!contexts) return ""; return Object.keys(contexts).filter(k => contexts[k]).join(", "); } export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) { return (

{title}

{children}
); } function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) { return (
{label &&
{label}
} {children}
); } function RowActions({ children }: { children: React.ReactNode }) { return (
{children}
); } function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDocMouseDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDocMouseDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDocMouseDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (items.length === 0) return null; return (
{open && (
{items.map((item, i) => { if (item.separator) { return
; } return ( ); })}
)}
); } function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) { return ( ); }