diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 2d051a8..a2b3306 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -19,6 +19,7 @@ import { Sidebar } from "@/components/gmail/sidebar" import { Header } from "@/components/gmail/header" import { EmailList } from "@/components/gmail/email-list" import { RightPanel } from "@/components/gmail/right-panel" +import { ContactsPanel } from "@/components/gmail/contacts/contacts-panel" import { EmailDragProvider } from "@/lib/drag-context" import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator" import { ComposeProvider } from "@/lib/compose-context" @@ -179,6 +180,7 @@ function MailAppInner() { > + {!splitView ? ( s.contacts) + const openContactDetail = useContactsStore((s) => s.openContactDetail) + const openCreateContact = useContactsStore((s) => s.openCreateContact) const [open, setOpen] = useState(false) const coarsePointer = useCoarsePointer() const triggerRef = useRef(null) @@ -60,6 +68,25 @@ export function ContactHoverCard({ const email = resolveSenderEmail(displayName, emailOverride) const color = avatarColor(name) + const matchedContact = useMemo( + () => findContactByEmail(contacts, email), + [contacts, email], + ) + + const openContactsPanel = useCallback(() => { + setOpen(false) + if (matchedContact) { + openContactDetail(matchedContact.id) + return + } + const { firstName, lastName } = parseDisplayNameToNameParts(name) + openCreateContact({ + firstName, + lastName, + emails: email ? [{ value: email, label: "Domicile" }] : undefined, + }) + }, [matchedContact, name, email, openContactDetail, openCreateContact]) + const openFromLongPress = useCallback(() => { allowHoverOpenRef.current = true setOpen(true) @@ -164,7 +191,13 @@ export function ContactHoverCard({ variant="ghost" size="icon" className="absolute right-0 top-0 h-8 w-8 shrink-0 text-[#5f6368] hover:bg-[#f1f3f4]" - aria-label="Ajouter aux contacts" + aria-label={ + matchedContact ? "Voir le contact" : "Ajouter aux contacts" + } + onClick={(e) => { + e.stopPropagation() + openContactsPanel() + }} > @@ -213,9 +246,22 @@ export function ContactHoverCard({ diff --git a/components/gmail/contacts/contact-detail-view.tsx b/components/gmail/contacts/contact-detail-view.tsx new file mode 100644 index 0000000..e489929 --- /dev/null +++ b/components/gmail/contacts/contact-detail-view.tsx @@ -0,0 +1,323 @@ +"use client" + +import { useMemo } from "react" +import { + ArrowLeft, + Pencil, + Star, + X, + Mail, + Phone, + Building2, + MapPin, + Cake, + FileText, + MessageSquare, + Video, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useContactsStore } from "@/lib/contacts/contacts-store" +import { fullContactDisplayName } from "@/lib/contacts/types" +import { avatarColor, senderInitial } from "@/lib/sender-display" +import { emails as allEmails } from "@/lib/email-data" +import { useComposeActions } from "@/lib/compose-context" +import { useNavStore } from "@/lib/stores/nav-store" + +interface ContactDetailViewProps { + contactId: string | null +} + +const FRENCH_MONTHS = [ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre", +] + +function formatBirthday(b: { day?: number; month?: number; year?: number }): string { + const parts: string[] = [] + if (b.day) parts.push(String(b.day)) + if (b.month) parts.push(FRENCH_MONTHS[b.month - 1] ?? "") + if (b.year) parts.push(String(b.year)) + return parts.join(" ") +} + +function formatEmailDate(iso: string): string { + const d = new Date(iso) + const now = new Date() + const diffDays = Math.floor((now.getTime() - d.getTime()) / 86_400_000) + if (diffDays === 0) return "Aujourd'hui" + if (diffDays === 1) return "Hier" + if (diffDays < 7) return `Il y a ${diffDays} jours` + return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }) +} + +export function ContactDetailView({ contactId }: ContactDetailViewProps) { + const { contacts, setView, closePanel } = useContactsStore() + const { openComposeWithInitial } = useComposeActions() + const labelRows = useNavStore((s) => s.labelRows) + + const contact = contacts.find((c) => c.id === contactId) + + const recentInteractions = useMemo(() => { + if (!contact) return [] + const contactEmails = new Set( + contact.emails.map((e) => e.value.toLowerCase()).filter(Boolean) + ) + if (contactEmails.size === 0) return [] + + return allEmails + .filter((email) => { + const se = email.senderEmail?.toLowerCase() + if (se && contactEmails.has(se)) return true + const senderLower = email.sender.toLowerCase() + return [...contactEmails].some((ce) => senderLower.includes(ce.split("@")[0] ?? "")) + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 10) + }, [contact]) + + if (!contact) { + return ( +
+ Contact introuvable +
+ ) + } + + const displayName = fullContactDisplayName(contact) + const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?" + const color = avatarColor(name) + const initial = senderInitial(name) + const primaryEmail = contact.emails[0]?.value + + return ( +
+ {/* Header */} +
+ +
+ + + +
+
+ + +
+ {/* Avatar + Name */} +
+ {contact.avatarUrl ? ( + {name} + ) : ( +
+ {initial} +
+ )} +

+ {name} +

+ {contact.company && ( +

+ {contact.jobTitle ? `${contact.jobTitle} — ` : ""} + {contact.company} +

+ )} + {contact.labels && contact.labels.length > 0 && ( +
+ {contact.labels.map((labelId) => { + const row = labelRows.find((r) => r.id === labelId) + return ( + + {row && ( + + )} + {row?.label ?? labelId} + + ) + })} +
+ )} +
+ + {/* Quick actions */} + {primaryEmail && ( +
+ + + +
+ )} + + {/* Contact details */} +
+ {contact.emails.length > 0 && ( + }> + {contact.emails.map((e, i) => ( +
+

{e.value}

+

{e.label}

+
+ ))} +
+ )} + + {contact.phones.length > 0 && ( + }> + {contact.phones.map((p, i) => ( +
+

{p.value}

+

{p.label}

+
+ ))} +
+ )} + + {contact.company && ( + }> +
+

{contact.company}

+ {contact.department && ( +

{contact.department}

+ )} + {contact.jobTitle && ( +

{contact.jobTitle}

+ )} +
+
+ )} + + {contact.addresses && contact.addresses.length > 0 && ( + }> + {contact.addresses.map((addr, i) => ( +
+

+ {[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country] + .filter(Boolean) + .join(", ")} +

+

{addr.label}

+
+ ))} +
+ )} + + {contact.birthday && (contact.birthday.day || contact.birthday.month) && ( + }> +

{formatBirthday(contact.birthday)}

+
+ )} + + {contact.notes && ( + }> +

{contact.notes}

+
+ )} +
+ + {/* Recent interactions */} + {recentInteractions.length > 0 && ( +
+

+ Interactions récentes +

+ {recentInteractions.map((email) => ( +
+ +
+

{email.subject}

+

+ {email.preview} +

+

{formatEmailDate(email.date)}

+
+
+ ))} +
+ )} + +
+
+
+ ) +} + +function DetailSection({ + icon, + children, +}: { + icon: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
{icon}
+
{children}
+
+ ) +} diff --git a/components/gmail/contacts/contact-form-view.tsx b/components/gmail/contacts/contact-form-view.tsx new file mode 100644 index 0000000..5f25707 --- /dev/null +++ b/components/gmail/contacts/contact-form-view.tsx @@ -0,0 +1,886 @@ +"use client" + +import { + useState, + useEffect, + useId, + forwardRef, + useCallback, + useRef, + type InputHTMLAttributes, +} from "react" +import { useForm, useFieldArray, Controller } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { + ArrowLeft, + Star, + X, + User, + Building2, + Mail, + Phone, + MapPin, + Cake, + FileText, + Plus, + ChevronDown, + ChevronUp, + Check, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useContactsStore } from "@/lib/contacts/contacts-store" +import { fullContactDisplayName } from "@/lib/contacts/types" +import { avatarColor, senderInitial } from "@/lib/sender-display" +import { useNavStore } from "@/lib/stores/nav-store" + +const FRENCH_MONTHS = [ + "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +] as const + +const EMAIL_LABELS = ["Domicile", "Travail", "Autre"] as const +const PHONE_LABELS = ["Mobile", "Domicile", "Travail"] as const +const ADDRESS_LABELS = ["Domicile", "Travail", "Autre"] as const + +const addressSchema = z.object({ + street: z.string().optional().default(""), + city: z.string().optional().default(""), + region: z.string().optional().default(""), + postalCode: z.string().optional().default(""), + country: z.string().optional().default(""), + label: z.string().default("Domicile"), +}) + +const contactFormSchema = z.object({ + namePrefix: z.string().optional().default(""), + firstName: z.string().optional().default(""), + middleName: z.string().optional().default(""), + lastName: z.string().optional().default(""), + nameSuffix: z.string().optional().default(""), + phoneticFirstName: z.string().optional().default(""), + phoneticLastName: z.string().optional().default(""), + company: z.string().optional().default(""), + department: z.string().optional().default(""), + jobTitle: z.string().optional().default(""), + emails: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), + phones: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), + addresses: z.array(addressSchema), + birthday: z + .object({ + day: z.any().optional(), + month: z.any().optional(), + year: z.any().optional(), + }) + .optional(), + notes: z.string().optional().default(""), + labels: z.array(z.string()).optional().default([]), +}) + +type ContactFormValues = z.infer + +interface ContactFormViewProps { + mode: "create" | "edit" + contactId?: string | null +} + +export function ContactFormView({ mode, contactId }: ContactFormViewProps) { + const { + contacts, + addContact, + updateContact, + setView, + closePanel, + createDraft, + clearCreateDraft, + } = useContactsStore() + const labelRows = useNavStore((s) => s.labelRows) + const [starred, setStarred] = useState(false) + const [nameExpanded, setNameExpanded] = useState(false) + const [companyExpanded, setCompanyExpanded] = useState(false) + + const existingContact = + mode === "edit" ? contacts.find((c) => c.id === contactId) : null + + const { + register, + handleSubmit, + control, + watch, + reset, + setValue, + formState: { isDirty }, + } = useForm({ + resolver: zodResolver(contactFormSchema), + defaultValues: { + namePrefix: "", + firstName: "", + middleName: "", + lastName: "", + nameSuffix: "", + phoneticFirstName: "", + phoneticLastName: "", + company: "", + department: "", + jobTitle: "", + emails: [{ value: "", label: "Domicile" }], + phones: [{ value: "", label: "Mobile" }], + addresses: [], + birthday: { day: undefined, month: undefined, year: undefined }, + notes: "", + labels: [], + }, + }) + + const { + fields: emailFields, + append: appendEmail, + remove: removeEmail, + } = useFieldArray({ control, name: "emails" }) + + const { + fields: phoneFields, + append: appendPhone, + remove: removePhone, + } = useFieldArray({ control, name: "phones" }) + + const { + fields: addressFields, + append: appendAddress, + remove: removeAddress, + } = useFieldArray({ control, name: "addresses" }) + + useEffect(() => { + if (mode !== "create" || !createDraft) return + reset({ + namePrefix: "", + firstName: createDraft.firstName ?? "", + middleName: "", + lastName: createDraft.lastName ?? "", + nameSuffix: "", + phoneticFirstName: "", + phoneticLastName: "", + company: "", + department: "", + jobTitle: "", + emails: createDraft.emails?.length + ? createDraft.emails + : [{ value: "", label: "Domicile" }], + phones: [{ value: "", label: "Mobile" }], + addresses: [], + birthday: { day: undefined, month: undefined, year: undefined }, + notes: "", + labels: [], + }, { shouldDirty: true }) + clearCreateDraft() + }, [mode, createDraft, reset, clearCreateDraft]) + + useEffect(() => { + if (existingContact) { + const hasExtendedName = !!( + existingContact.namePrefix || + existingContact.middleName || + existingContact.nameSuffix || + existingContact.phoneticFirstName || + existingContact.phoneticLastName + ) + if (hasExtendedName) setNameExpanded(true) + if (existingContact.department) setCompanyExpanded(true) + + reset({ + namePrefix: existingContact.namePrefix ?? "", + firstName: existingContact.firstName, + middleName: existingContact.middleName ?? "", + lastName: existingContact.lastName, + nameSuffix: existingContact.nameSuffix ?? "", + phoneticFirstName: existingContact.phoneticFirstName ?? "", + phoneticLastName: existingContact.phoneticLastName ?? "", + company: existingContact.company ?? "", + department: existingContact.department ?? "", + jobTitle: existingContact.jobTitle ?? "", + emails: existingContact.emails.length + ? existingContact.emails + : [{ value: "", label: "Domicile" }], + phones: existingContact.phones.length + ? existingContact.phones + : [{ value: "", label: "Mobile" }], + addresses: existingContact.addresses ?? [], + birthday: existingContact.birthday ?? { + day: undefined, + month: undefined, + year: undefined, + }, + notes: existingContact.notes ?? "", + labels: existingContact.labels ?? [], + }) + } + }, [existingContact, reset]) + + const firstName = watch("firstName") + const lastName = watch("lastName") + const watchedEmails = watch("emails") + const currentLabels = watch("labels") ?? [] + const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim() + + const canSave = + isDirty || + (mode === "create" && + !!( + firstName?.trim() || + lastName?.trim() || + watchedEmails?.some((e) => e.value?.trim()) + )) + + const toggleLabel = useCallback( + (labelId: string) => { + const next = currentLabels.includes(labelId) + ? currentLabels.filter((l) => l !== labelId) + : [...currentLabels, labelId] + setValue("labels", next, { shouldDirty: true }) + }, + [currentLabels, setValue], + ) + + function onSubmit(data: ContactFormValues) { + const payload = { + namePrefix: data.namePrefix || undefined, + firstName: data.firstName ?? "", + middleName: data.middleName || undefined, + lastName: data.lastName ?? "", + nameSuffix: data.nameSuffix || undefined, + phoneticFirstName: data.phoneticFirstName || undefined, + phoneticLastName: data.phoneticLastName || undefined, + company: data.company || undefined, + department: data.department || undefined, + jobTitle: data.jobTitle || undefined, + emails: data.emails.filter((e) => e.value), + phones: data.phones.filter((p) => p.value), + addresses: data.addresses.filter( + (a) => a.street || a.city || a.region || a.postalCode || a.country, + ), + birthday: + data.birthday?.day || data.birthday?.month || data.birthday?.year + ? data.birthday + : undefined, + notes: data.notes || undefined, + labels: data.labels?.length ? data.labels : undefined, + } + + if (mode === "create") { + const id = addContact(payload) + setView("view", id) + } else if (contactId) { + updateContact(contactId, payload) + setView("view", contactId) + } + } + + const availableLabels = labelRows.filter((r) => r.enabled !== false) + + return ( +
+
+ + +
+ + + + + +
+
+ +
+ {/* Avatar */} +
+ {displayName ? ( +
+ {senderInitial(displayName)} +
+ ) : ( +
+ +
+ )} +
+ + {/* Labels */} +
+ {currentLabels.map((labelId) => { + const row = labelRows.find((r) => r.id === labelId) + return ( + + {row && ( + + )} + {row?.label ?? labelId} + + + ) + })} + + + + + +

+ Libellés +

+
+ {availableLabels.map((row) => { + const active = currentLabels.includes(row.id) + return ( + + ) + })} +
+
+
+
+ + {/* Name section */} + }> + {nameExpanded && ( + + )} +
+
+ +
+ +
+ {nameExpanded && ( + + )} + + {nameExpanded && ( + <> + + + + + )} +
+ + {/* Company section */} + }> +
+
+ +
+ +
+ {companyExpanded && ( + + )} + +
+ + {/* Email section */} + }> + {emailFields.map((field, index) => ( +
+
+
+ +
+ {emailFields.length > 1 && ( + + )} +
+ ( + ({ value: l, label: l }))} + /> + )} + /> +
+ ))} + appendEmail({ value: "", label: "Domicile" })}> + Ajouter une adresse e-mail + +
+ + {/* Phone section */} + }> + {phoneFields.map((field, index) => ( +
+
+ + 🇫🇷 + +
+ +
+ {phoneFields.length > 1 && ( + + )} +
+ ( + ({ value: l, label: l }))} + /> + )} + /> +
+ ))} + appendPhone({ value: "", label: "Mobile" })}> + Ajouter un numéro de téléphone + +
+ + {/* Address section */} + }> + {addressFields.map((field, index) => ( +
+
+ ( + ({ value: l, label: l }))} + /> + )} + /> + +
+ +
+
+ +
+
+ +
+
+ + +
+ ))} + + appendAddress({ + street: "", + city: "", + region: "", + postalCode: "", + country: "", + label: "Domicile", + }) + } + > + Ajouter une adresse + +
+ + {/* Birthday section */} + }> +
+
+ +
+
+ ( + f.onChange(v ? Number(v) : undefined)} + options={FRENCH_MONTHS.map((name, i) => ({ + value: String(i + 1), + label: name, + }))} + placeholder="Mois" + /> + )} + /> +
+
+ +
+
+
+ + {/* Notes section */} + }> + + + +
+
+ + ) +} + +/* ─── Layout helpers ─────────────────────────────────────────── */ + +function FormSection({ + icon, + children, +}: { + icon: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
{icon}
+
{children}
+
+ ) +} + +function AddButton({ + onClick, + children, +}: { + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +/* ─── Floating label input (Material outlined style) ─────── */ + +interface FloatingInputProps extends InputHTMLAttributes { + label: string +} + +const FloatingInput = forwardRef( + function FloatingInput({ label, className, defaultValue, ...props }, ref) { + const id = useId() + const [focused, setFocused] = useState(false) + const [filled, setFilled] = useState(() => !!defaultValue) + const innerRef = useRef(null) + + useEffect(() => { + if (innerRef.current && innerRef.current.value) setFilled(true) + }) + + const setRefs = useCallback( + (node: HTMLInputElement | null) => { + innerRef.current = node + if (typeof ref === "function") ref(node) + else if (ref) ref.current = node + if (node && node.value) setFilled(true) + }, + [ref], + ) + + const floated = focused || filled + + return ( +
+ { + setFocused(true) + props.onFocus?.(e) + }} + onBlur={(e) => { + setFocused(false) + setFilled(!!e.target.value) + props.onBlur?.(e) + }} + onChange={(e) => { + setFilled(!!e.target.value) + props.onChange?.(e) + }} + /> + +
+ ) + }, +) + +/* ─── Floating label textarea ────────────────────────────── */ + +interface FloatingTextareaProps + extends React.TextareaHTMLAttributes { + label: string +} + +const FloatingTextarea = forwardRef( + function FloatingTextarea({ label, className, ...props }, ref) { + const id = useId() + const [focused, setFocused] = useState(false) + const [filled, setFilled] = useState(false) + const innerRef = useRef(null) + + useEffect(() => { + if (innerRef.current && innerRef.current.value) setFilled(true) + }) + + const setRefs = useCallback( + (node: HTMLTextAreaElement | null) => { + innerRef.current = node + if (typeof ref === "function") ref(node) + else if (ref) ref.current = node + if (node && node.value) setFilled(true) + }, + [ref], + ) + + const floated = focused || filled + + return ( +
+