"use client"; import { useState, useMemo, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; import { normalizeContactPhotoUri } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; interface EmailEntry { address: string; context: "work" | "private" | ""; } interface PhoneEntry { number: string; context: "work" | "private" | ""; feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | ""; } interface OnlineServiceEntry { uri: string; service: string; label: string; } interface AnniversaryEntry { date: string; kind: "birth" | "death" | "wedding" | "other"; } interface PersonalInfoEntry { value: string; kind: "expertise" | "hobby" | "interest" | "other"; level: "high" | "medium" | "low" | ""; } interface AddressEntry { street: string; locality: string; region: string; postcode: string; country: string; context: "work" | "private" | ""; } interface ContactFormProps { contact?: ContactCard | null; addressBooks?: AddressBook[]; allKeywords?: string[]; defaultAddressBookId?: string; /** Prefills the create form (ignored when `contact` is set). */ prefill?: { email?: string; name?: string }; onSave: (data: Partial) => Promise; onCancel: () => void; } function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; collapsible?: boolean; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen); return (
{(open || !collapsible) && (
{children}
)}
); } const MAX_PHOTO_DIM = 512; const PHOTO_QUALITY = 0.85; async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = () => { const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height)); const w = Math.max(1, Math.round(img.width * ratio)); const h = Math.max(1, Math.round(img.height * ratio)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("canvas-unsupported")); return; } ctx.drawImage(img, 0, 0, w, h); const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY); resolve({ uri, mediaType: "image/jpeg" }); }; img.onerror = () => reject(new Error("invalid-image")); img.src = reader.result as string; }; reader.onerror = () => reject(new Error("read-failed")); reader.readAsDataURL(file); }); } function Select({ value, onChange, children, className }: { value: string; onChange: (e: React.ChangeEvent) => void; children: React.ReactNode; className?: string; }) { return ( ); } export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; // Split a free-form display name into given/surname for prefill. const prefillGivenName = (() => { if (contact || !prefill?.name) return ""; const parts = prefill.name.trim().split(/\s+/); return parts[0] || ""; })(); const prefillSurname = (() => { if (contact || !prefill?.name) return ""; const parts = prefill.name.trim().split(/\s+/); return parts.slice(1).join(" "); })(); // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. const findComponent = (...kinds: string[]) => contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; // Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input function anniversaryDateToString(date: AnniversaryDate): string { if (typeof date === 'string') return date; if (date && typeof date === 'object') { if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) { return (date as { utc: string }).utc.split('T')[0]; } const pd = date as PartialDate; if (pd.year && pd.month && pd.day) { return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; } if (pd.month && pd.day) { return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`; } if (pd.year && pd.month) { return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`; } if (pd.year) return String(pd.year); } return String(date); } // Convert ISO date string back to RFC 9553 PartialDate for the server function stringToPartialDate(str: string): PartialDate { if (str.startsWith('--')) { const parts = str.substring(2).split('-'); const pd: PartialDate = { month: parseInt(parts[0], 10) }; if (parts[1]) pd.day = parseInt(parts[1], 10); return pd; } const parts = str.split('-'); const pd: PartialDate = {}; if (parts[0]) pd.year = parseInt(parts[0], 10); if (parts[1]) pd.month = parseInt(parts[1], 10); if (parts[2]) pd.day = parseInt(parts[2], 10); return pd; } // Extract flat address fields from RFC 9553 components format function addressToFlat(a: ContactAddress): AddressEntry { if (a.components && a.components.length > 0) { const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' '); return { street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '', locality: findComp('locality'), region: findComp('region'), postcode: findComp('postcode'), country: findComp('country'), context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', }; } return { street: a.street || '', locality: a.locality || '', region: a.region || '', postcode: a.postcode || '', country: a.country || '', context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '', }; } const [prefix, setPrefix] = useState(findComponent("title", "prefix")); const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); const [surname, setSurname] = useState(findComponent("surname") || prefillSurname); const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [nickname, setNickname] = useState( contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : "" ); const [emails, setEmails] = useState(() => { if (contact?.emails) { return Object.values(contact.emails).map(e => ({ address: e.address, context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", })); } return [{ address: prefill?.email || "", context: "" }]; }); const [phones, setPhones] = useState(() => { if (contact?.phones) { return Object.values(contact.phones).map(p => ({ number: p.number, context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "", feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "", })); } return []; }); const [organization, setOrganization] = useState( contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : "" ); const [department, setDepartment] = useState( contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : "" ); const [jobTitle, setJobTitle] = useState(() => { if (contact?.titles) { const t = Object.values(contact.titles).find(t => t.kind !== "role"); return t?.name || ""; } return ""; }); const [role, setRole] = useState(() => { if (contact?.titles) { const r = Object.values(contact.titles).find(t => t.kind === "role"); return r?.name || ""; } return ""; }); const [addresses, setAddresses] = useState(() => { if (contact?.addresses) { return Object.values(contact.addresses).map(a => addressToFlat(a)); } return []; }); const [onlineServices, setOnlineServices] = useState(() => { if (contact?.onlineServices) { return Object.values(contact.onlineServices).map(s => ({ uri: s.uri, service: s.service || "", label: s.label || "", })); } return []; }); const [anniversaries, setAnniversaries] = useState(() => { if (contact?.anniversaries) { return Object.values(contact.anniversaries).map(a => ({ date: anniversaryDateToString(a.date), kind: a.kind, })); } return []; }); const [personalInfoEntries, setPersonalInfoEntries] = useState(() => { if (contact?.personalInfo) { return Object.values(contact.personalInfo).map(p => ({ value: p.value, kind: p.kind, level: p.level || "", })); } return []; }); const [keywordsStr, setKeywordsStr] = useState( contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : "" ); const [note, setNote] = useState( contact?.notes ? Object.values(contact.notes)[0]?.note || "" : "" ); const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || ""); const [genderIdentity, setGenderIdentity] = useState( contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : "" ); const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || ""); const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || ""); const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || ""); // Address book selection const currentBookId = useMemo(() => { if (contact?.addressBookIds) { const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]); if (ids.length > 0) { // addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId") // so we can use them directly to match addressBook entries return ids[0]; } } if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) { return defaultAddressBookId; } return ""; }, [contact, defaultAddressBookId, addressBooks]); const [selectedBookId, setSelectedBookId] = useState(currentBookId); const initialPhotoEntry = useMemo(() => { if (!contact?.media) return null; for (const [key, m] of Object.entries(contact.media)) { if (m.kind === "photo" && m.uri) { return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType }; } } return null; }, [contact]); const [photoUri, setPhotoUri] = useState(initialPhotoEntry?.uri); const [photoMediaType, setPhotoMediaType] = useState(initialPhotoEntry?.mediaType); const [photoError, setPhotoError] = useState(null); const [photoUploading, setPhotoUploading] = useState(false); const photoInputRef = useRef(null); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [emailErrors, setEmailErrors] = useState>({}); const handlePhotoSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; if (!file.type.startsWith("image/")) { setPhotoError(t("photo_invalid")); return; } if (file.size > 10 * 1024 * 1024) { setPhotoError(t("photo_too_large")); return; } setPhotoError(null); setPhotoUploading(true); try { const { uri, mediaType } = await processImageFile(file); setPhotoUri(uri); setPhotoMediaType(mediaType); } catch { setPhotoError(t("photo_invalid")); } finally { setPhotoUploading(false); } }; const handlePhotoRemove = () => { setPhotoUri(undefined); setPhotoMediaType(undefined); setPhotoError(null); }; const validateEmail = (address: string): boolean => { if (!address.trim()) return true; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim()); }; const handleEmailBlur = (index: number, address: string) => { if (address.trim() && !validateEmail(address)) { setEmailErrors(prev => ({ ...prev, [index]: t("email_error_inline") })); } else { setEmailErrors(prev => { const next = { ...prev }; delete next[index]; return next; }); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!givenName.trim() && !surname.trim()) { setError(t("name_required")); return; } const validEmails = emails.filter(e => e.address.trim()); for (const entry of validEmails) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry.address.trim())) { setError(t("email_invalid")); return; } } const emailsMap: Record }> = {}; validEmails.forEach((entry, i) => { const obj: { address: string; contexts?: Record } = { address: entry.address.trim() }; if (entry.context) { obj.contexts = { [entry.context]: true }; } emailsMap[`e${i}`] = obj; }); const validPhones = phones.filter(p => p.number.trim()); const phonesMap: Record; features?: Record }> = {}; validPhones.forEach((entry, i) => { const obj: { number: string; contexts?: Record; features?: Record } = { number: entry.number.trim() }; if (entry.context) { obj.contexts = { [entry.context]: true }; } if (entry.feature) { obj.features = { [entry.feature]: true }; } phonesMap[`p${i}`] = obj; }); // Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly. const nameComponents = []; if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() }); if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() }); if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() }); const titlesMap: Record = {}; if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" }; const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined; const addressesMap: Record ? V : never> = {}; addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => { const components: Array<{ kind: string; value: string }> = []; if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() }); if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() }); if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() }); if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() }); if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() }); const obj: Record = { components, isOrdered: true, defaultSeparator: ", " }; if (a.context) obj.contexts = { [a.context]: true }; // @ts-expect-error - dynamic build addressesMap[`a${i}`] = obj; }); const onlineServicesMap: Record = {}; onlineServices.filter(s => s.uri.trim()).forEach((s, i) => { const obj: ContactOnlineService = { uri: s.uri.trim() }; if (s.service.trim()) obj.service = s.service.trim(); if (s.label.trim()) obj.label = s.label.trim(); onlineServicesMap[`os${i}`] = obj; }); const anniversariesMap: Record = {}; anniversaries.filter(a => a.date.trim()).forEach((a, i) => { anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind }; }); const personalInfoMap: Record = {}; personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => { const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind }; if (p.level) obj.level = p.level as "high" | "medium" | "low"; personalInfoMap[`pi${i}`] = obj; }); const keywordsMap: Record = {}; if (keywordsStr.trim()) { keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { keywordsMap[k] = true; }); } const mediaMap: Record = {}; if (contact?.media) { for (const [key, m] of Object.entries(contact.media)) { if (m.kind !== "photo") mediaMap[key] = m; } } if (photoUri) { const photoKey = initialPhotoEntry?.key || "photo"; mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType }; } const data: Partial = { name: { components: nameComponents, isOrdered: true }, nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined, phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined, titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined, organizations: organization.trim() ? { o0: { name: organization.trim(), units: orgUnits } } : undefined, addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined, onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined, anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined, personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined, keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined, notes: note.trim() ? { n0: { note: note.trim() } } : undefined, speakToAs: (genderSex.trim() || genderIdentity.trim()) ? { grammaticalGender: genderSex.trim() || undefined, pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined, } : undefined, calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined, media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined, ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; setIsSaving(true); try { await onSave(data); } catch (err) { setError(err instanceof Error ? err.message : t("save_failed")); } finally { setIsSaving(false); } }; const previewName = [givenName, surname].filter(Boolean).join(" ").trim(); const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; return (

{isEditing ? t("edit_title") : t("create_title")}

{error && (
{error}
)}

{t("photo_hint")}

{photoError && (

{photoError}

)} {photoUri && ( )}
{addressBooks && addressBooks.length > 1 && ( )}
setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
setSurname(e.target.value)} placeholder={t("surname")} />
setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
{/* Email */}
{emails.map((entry, i) => (
{ const next = [...emails]; next[i] = { ...next[i], address: e.target.value }; setEmails(next); if (emailErrors[i]) { setEmailErrors(prev => { const n = { ...prev }; delete n[i]; return n; }); } }} onBlur={() => handleEmailBlur(i, entry.address)} placeholder={t("email_placeholder")} className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")} /> {emails.length > 1 && ( )}
{emailErrors[i] && (

{emailErrors[i]}

)}
))}
{/* Phone */}
{phones.map((entry, i) => (
{ const next = [...phones]; next[i] = { ...next[i], number: e.target.value }; setPhones(next); }} placeholder={t("phone_placeholder")} className="flex-1" />
))}
{/* Work & Organization */}
setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
setJobTitle(e.target.value)} placeholder={t("job_title_placeholder")} />
setRole(e.target.value)} placeholder={t("role_placeholder")} />
{/* Addresses */} 0}>
{addresses.map((addr, i) => (
{ const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
{ const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} /> { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} />
{ const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} /> { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} />
))}
{/* Online Services */} 0}>
{onlineServices.map((svc, i) => (
{ const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }} placeholder={t("url_placeholder")} className="flex-1" /> { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }} placeholder={t("service_placeholder")} className="w-24" />
))}
{/* Anniversaries */} 0}>
{anniversaries.map((ann, i) => (
{ const n = [...anniversaries]; n[i] = { ...n[i], date: e.target.value }; setAnniversaries(n); }} className="flex-1" />
))}
{/* Personal Info */} 0}>
{personalInfoEntries.map((pi, i) => (
{ const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }} placeholder={t("personal_info_placeholder")} className="flex-1" />
))}
{/* Categories */} {/* Gender */}
setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
{/* Calendar */}
setCalendarUri(e.target.value)} placeholder="https://..." />
setSchedulingUri(e.target.value)} placeholder="https://..." />
setFreeBusyUri(e.target.value)} placeholder="https://..." />
{/* Notes */}