Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import {
|
|
Pencil,
|
|
Sparkles,
|
|
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 { useContactsList } from "@/lib/contacts/use-contacts-list"
|
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
|
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
|
import { useComposeActions } from "@/lib/compose-context"
|
|
import { useNavStore } from "@/lib/stores/nav-store"
|
|
import {
|
|
CONTACTS_HEADING_TEXT,
|
|
CONTACTS_MUTED_TEXT,
|
|
CONTACTS_PANEL_DIVIDER_CLASS,
|
|
CONTACTS_PANEL_HEADER_CLASS,
|
|
CONTACTS_PANEL_ICON_BTN_CLASS,
|
|
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
|
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
|
|
CONTACTS_PANEL_ROW_CLASS,
|
|
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
|
|
CONTACTS_PANEL_SHELL_CLASS,
|
|
CONTACTS_PANEL_TAG_CLASS,
|
|
} from "@/lib/contacts-chrome-classes"
|
|
import { cn } from "@/lib/utils"
|
|
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
|
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
|
|
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
|
|
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
|
|
|
|
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 { setView, showContactsList, closePanel } = useContactsStore()
|
|
const { contacts } = useContactsList()
|
|
const { openComposeWithInitial } = useComposeActions()
|
|
const labelRows = useNavStore((s) => s.labelRows)
|
|
const { data: llmSettings } = useLLMSettings()
|
|
const [improveOpen, setImproveOpen] = useState(false)
|
|
const llmReady = isLLMConfigured(llmSettings)
|
|
|
|
const contact = contacts.find((c) => c.id === contactId)
|
|
|
|
const primaryContactEmail = contact?.emails[0]?.value
|
|
const { data: searchResult } = useMailSearch(
|
|
primaryContactEmail ? { from: primaryContactEmail } : null
|
|
)
|
|
const recentInteractions = useMemo(() => {
|
|
if (!searchResult?.data) return []
|
|
return searchResult.data.slice(0, 10).map((msg) => ({
|
|
id: msg.id,
|
|
subject: msg.subject,
|
|
preview: msg.snippet,
|
|
date: msg.date,
|
|
}))
|
|
}, [searchResult])
|
|
|
|
if (!contact) {
|
|
return (
|
|
<div className={cn("flex h-full items-center justify-center text-sm", CONTACTS_MUTED_TEXT)}>
|
|
Contact introuvable
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const displayName = fullContactDisplayName(contact)
|
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
|
const primaryEmail = contact.emails[0]?.value
|
|
|
|
return (
|
|
<div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
|
|
{/* Header */}
|
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
onClick={() => setImproveOpen(true)}
|
|
disabled={!llmReady}
|
|
aria-label="Amélioration IA"
|
|
title={
|
|
llmReady
|
|
? "Améliorer la fiche avec l'IA"
|
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
|
}
|
|
>
|
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
onClick={() => setView("edit", contactId)}
|
|
aria-label="Modifier"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
>
|
|
<Star className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
onClick={closePanel}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden [&_[data-slot=scroll-area-viewport]>div]:!block [&_[data-slot=scroll-area-viewport]>div]:min-w-0 [&_[data-slot=scroll-area-viewport]>div]:max-w-full">
|
|
<div className="w-full min-w-0 max-w-full overflow-x-hidden">
|
|
{/* Avatar + Name */}
|
|
<div className="flex flex-col items-center px-4 pt-6 pb-4">
|
|
<ContactAvatar contact={contact} name={name} size="lg" />
|
|
<h2 className={cn("mt-3 max-w-full truncate px-2 text-center text-lg font-medium", CONTACTS_HEADING_TEXT)}>
|
|
{name}
|
|
</h2>
|
|
{contact.company && (
|
|
<p className={cn("max-w-full truncate px-2 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
|
{contact.jobTitle ? `${contact.jobTitle} — ` : ""}
|
|
{contact.company}
|
|
</p>
|
|
)}
|
|
{contact.labels && contact.labels.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{contact.labels.map((labelId) => {
|
|
const row = labelRows.find((r) => r.id === labelId)
|
|
return (
|
|
<span
|
|
key={labelId}
|
|
className={CONTACTS_PANEL_TAG_CLASS}
|
|
>
|
|
{row && (
|
|
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
|
)}
|
|
{row?.label ?? labelId}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick actions */}
|
|
<div className="flex min-w-0 flex-col items-center gap-2 px-4 pb-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full max-w-xs rounded-full"
|
|
onClick={() => setImproveOpen(true)}
|
|
disabled={!llmReady}
|
|
title={
|
|
llmReady
|
|
? undefined
|
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
|
}
|
|
>
|
|
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
|
|
Amélioration IA
|
|
</Button>
|
|
{primaryEmail && (
|
|
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2">
|
|
<button
|
|
type="button"
|
|
className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}
|
|
onClick={() =>
|
|
openComposeWithInitial({
|
|
to: [{ name: displayName, email: primaryEmail }],
|
|
})
|
|
}
|
|
>
|
|
<Mail className="h-4 w-4" />
|
|
Envoyer un e-mail
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}
|
|
>
|
|
<Video className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Contact details */}
|
|
<div className={cn("min-w-0", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
|
{contact.emails.length > 0 && (
|
|
<DetailSection icon={<Mail className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{contact.emails.map((e, i) => (
|
|
<div key={i}>
|
|
<p className="truncate text-sm text-primary">{e.value}</p>
|
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{e.label}</p>
|
|
</div>
|
|
))}
|
|
</DetailSection>
|
|
)}
|
|
|
|
{contact.phones.length > 0 && (
|
|
<DetailSection icon={<Phone className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{contact.phones.map((p, i) => (
|
|
<div key={i}>
|
|
<p className="text-sm text-primary">{p.value}</p>
|
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{p.label}</p>
|
|
</div>
|
|
))}
|
|
</DetailSection>
|
|
)}
|
|
|
|
{contact.company && (
|
|
<DetailSection icon={<Building2 className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<div>
|
|
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{contact.company}</p>
|
|
{contact.department && (
|
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.department}</p>
|
|
)}
|
|
{contact.jobTitle && (
|
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.jobTitle}</p>
|
|
)}
|
|
</div>
|
|
</DetailSection>
|
|
)}
|
|
|
|
{contact.addresses && contact.addresses.length > 0 && (
|
|
<DetailSection icon={<MapPin className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{contact.addresses.map((addr, i) => (
|
|
<div key={i}>
|
|
<p className={cn("break-words text-sm [overflow-wrap:anywhere]", CONTACTS_HEADING_TEXT)}>
|
|
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
|
|
.filter(Boolean)
|
|
.join(", ")}
|
|
</p>
|
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{addr.label}</p>
|
|
</div>
|
|
))}
|
|
</DetailSection>
|
|
)}
|
|
|
|
{contact.birthday && (contact.birthday.day || contact.birthday.month) && (
|
|
<DetailSection icon={<Cake className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{formatBirthday(contact.birthday)}</p>
|
|
</DetailSection>
|
|
)}
|
|
|
|
{contact.notes && (
|
|
<DetailSection icon={<FileText className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<p className={cn("whitespace-pre-wrap text-sm", CONTACTS_HEADING_TEXT)}>{contact.notes}</p>
|
|
</DetailSection>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent interactions */}
|
|
{recentInteractions.length > 0 && (
|
|
<div className={cn("min-w-0 overflow-hidden pt-3 pb-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
|
<h3 className={cn("px-4 pb-2 text-xs font-medium uppercase", CONTACTS_MUTED_TEXT)}>
|
|
Interactions récentes
|
|
</h3>
|
|
{recentInteractions.map((email) => (
|
|
<div
|
|
key={email.id}
|
|
className={cn("flex min-w-0 gap-3 overflow-hidden px-4 py-2", CONTACTS_PANEL_ROW_CLASS)}
|
|
>
|
|
<Mail className={cn("mt-0.5 h-4 w-4 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{email.subject}</p>
|
|
<p className={cn("line-clamp-2 break-words [overflow-wrap:anywhere] text-xs", CONTACTS_MUTED_TEXT)}>
|
|
{email.preview}
|
|
</p>
|
|
<p className={cn("mt-0.5 text-xs", CONTACTS_MUTED_TEXT)}>{formatEmailDate(email.date)}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<ContactImproveDialog
|
|
contact={contact}
|
|
open={improveOpen}
|
|
onOpenChange={setImproveOpen}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DetailSection({
|
|
icon,
|
|
children,
|
|
}: {
|
|
icon: React.ReactNode
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex min-w-0 gap-3 px-4 py-3">
|
|
<div className="flex w-5 shrink-0 pt-0.5">{icon}</div>
|
|
<div className="min-w-0 flex-1 space-y-2 overflow-hidden">{children}</div>
|
|
</div>
|
|
)
|
|
}
|