ultisuite-client/components/gmail/contacts-page/suggested-contact-card.tsx
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- 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.
2026-06-06 20:26:51 +02:00

294 lines
9.6 KiB
TypeScript

"use client"
import { memo, useEffect, useMemo, useState } from "react"
import { Ban, Loader2, Sparkles, Trash2, UserPlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
import {
applyDraftToContact,
buildDraftFields,
type DraftField,
} from "@/lib/contacts/discovery-draft"
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
import type { FullContact } from "@/lib/contacts/types"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_DISCOVERY_INNER_PANEL_CLASS,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { DiscoveryFieldChips } from "@/components/gmail/contacts-page/discovery-field-chips"
import { cn } from "@/lib/utils"
export type SuggestedContactCardMode = "suggest" | "ignored" | "blocked"
const ACTION_BTN_CLASS =
"h-9 min-w-0 w-full rounded-full px-3 text-sm font-medium sm:w-auto sm:flex-none"
const BLOCK_BTN_CLASS = cn(
"mx-auto h-9 w-auto min-w-0 px-3 text-sm font-medium text-destructive underline-offset-4",
"bg-transparent shadow-none hover:bg-transparent hover:text-destructive/90 hover:underline",
"dark:bg-transparent dark:hover:bg-transparent sm:mx-0 sm:flex-none",
"[&_svg]:text-destructive",
)
const ACTION_BTN_ICON_CLASS = "mr-1.5 h-3.5 w-3.5 shrink-0"
const IGNORE_BTN_CLASS =
"h-8 shrink-0 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
interface SuggestedContactCardProps {
group: ApiDiscoveredProfileGroup
mode?: SuggestedContactCardMode
busy?: boolean
/** Spinner sur « Ajouter » uniquement (sinon busy désactive sans spinner sur les autres boutons). */
addBusy?: boolean
/** Le parent retire la carte avant d'appeler buildContact (feedback instantané). */
onAdd?: (buildContact: () => FullContact) => void
onEnrich?: () => void
onIgnore?: () => void
onBlock?: () => void
onRemove?: () => void
}
function formatMailboxes(profile: ApiDiscoveredProfile): string {
const accounts = profile.detected_in_accounts ?? []
if (accounts.length === 0) return ""
return accounts
.map((a) => {
const label = a.account_name || a.account_email
return `${label} (${a.message_count} msg)`
})
.join(", ")
}
function toChipItems(fields: DraftField[]) {
return fields.map((f) => ({
id: f.id,
fieldKey: f.fieldKey,
value: f.value,
removed: f.removed,
}))
}
export const SuggestedContactCard = memo(function SuggestedContactCard({
group,
mode = "suggest",
busy = false,
addBusy = false,
onAdd,
onEnrich,
onIgnore,
onBlock,
onRemove,
}: SuggestedContactCardProps) {
const profile = group.profile ?? group.profiles?.[0]
const [fields, setFields] = useState<DraftField[]>(() => buildDraftFields(group))
useEffect(() => {
setFields(buildDraftFields(group))
}, [
group.group_key,
group.profile?.id,
group.profile_ids?.[0],
group.profile?.enrichment_status,
group.profile?.enriched_data,
])
const chipItems = useMemo(() => toChipItems(fields), [fields])
if (!profile) return null
const name = group.display_name || profileDisplayName(profile)
const mailboxes = formatMailboxes(profile)
const extraEmails = (group.profiles?.length ?? 1) > 1
const hasSignatures = (profile.signatures?.length ?? 0) > 0
const isEnriching = profile.enrichment_status === "enriching"
const showEnrichIA = hasSignatures && profile.enrichment_status !== "enriched"
function removeField(id: string) {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, removed: true } : f)))
}
function updateField(id: string, value: string) {
setFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, value, removed: false } : f)),
)
}
function buildContactFromDraft() {
return applyDraftToContact(profile, fields)
}
return (
<div className={CONTACTS_PAGE_CARD_CLASS}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-3">
<ContactAvatar name={name} email={profile.primary_email} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<p className={cn("truncate text-sm font-medium leading-tight", CONTACTS_HEADING_TEXT)}>
{name}
</p>
{isEnriching && (
<Loader2
className="h-3.5 w-3.5 shrink-0 animate-spin text-amber-500 dark:text-amber-400"
aria-label="Enrichissement IA en cours"
/>
)}
{profile.enrichment_status === "enriched" && (
<Sparkles
className="h-3.5 w-3.5 shrink-0 text-amber-500 dark:text-amber-400"
aria-label="Coordonnées enrichies par IA"
/>
)}
{mode === "blocked" && (
<span className={cn("shrink-0 text-[10px] font-medium", CONTACTS_MUTED_TEXT)}>
· bloqué
</span>
)}
{mode === "ignored" && (
<span className={cn("shrink-0 text-[10px] font-medium", CONTACTS_MUTED_TEXT)}>
· ignoré
</span>
)}
</div>
{extraEmails ? (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
{(group.profile_ids?.length ?? 0)} adresses e-mail regroupées
</p>
) : (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{group.primary_email}</p>
)}
{mailboxes && (
<p className={cn("mt-0.5 line-clamp-2 text-xs", CONTACTS_MUTED_TEXT)}>
{mailboxes}
</p>
)}
</div>
</div>
{mode === "suggest" && onIgnore && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onIgnore}
disabled={busy}
className={IGNORE_BTN_CLASS}
>
Ignorer
</Button>
)}
</div>
<DiscoveryFieldChips
items={chipItems}
denseGrid={mode === "suggest"}
editable={mode === "suggest" || mode === "ignored" || mode === "blocked"}
onRemove={removeField}
onValueChange={updateField}
/>
{profile.signatures && profile.signatures.length > 0 && mode === "suggest" && (
<details className="mt-2">
<summary className={cn("cursor-pointer text-[11px]", CONTACTS_MUTED_TEXT)}>
{profile.signatures.length} signature{profile.signatures.length > 1 ? "s" : ""}
</summary>
<pre
className={cn(
"mt-1 max-h-24 overflow-auto text-[11px] whitespace-pre-wrap",
CONTACTS_DISCOVERY_INNER_PANEL_CLASS,
)}
>
{profile.signatures[0].signature_text}
</pre>
</details>
)}
{mode === "suggest" && (
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
{showEnrichIA && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onEnrich}
disabled={isEnriching || busy}
className={ACTION_BTN_CLASS}
aria-label="Enrichissement IA à partir des signatures"
>
{isEnriching ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<Sparkles className={ACTION_BTN_ICON_CLASS} />
)}
Enrichissement IA
</Button>
)}
<Button
type="button"
variant="link"
size="sm"
onClick={onBlock}
disabled={busy || !onBlock}
className={BLOCK_BTN_CLASS}
>
<Ban className={ACTION_BTN_ICON_CLASS} />
Bloquer
</Button>
<Button
type="button"
size="sm"
onClick={() => onAdd?.(buildContactFromDraft)}
disabled={busy || !onAdd}
className={cn(CONTACTS_PRIMARY_BTN_CLASS, ACTION_BTN_CLASS)}
>
{addBusy ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<UserPlus className={ACTION_BTN_ICON_CLASS} />
)}
Ajouter aux contacts
</Button>
</div>
)}
{(mode === "ignored" || mode === "blocked") && (
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
{onRemove && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRemove}
disabled={busy}
className={ACTION_BTN_CLASS}
>
<Trash2 className={ACTION_BTN_ICON_CLASS} />
Supprimer
</Button>
)}
{onAdd && (
<Button
type="button"
size="sm"
onClick={() => onAdd(buildContactFromDraft)}
disabled={busy}
className={cn(CONTACTS_PRIMARY_BTN_CLASS, ACTION_BTN_CLASS)}
>
{addBusy ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<UserPlus className={ACTION_BTN_ICON_CLASS} />
)}
Ajouter aux contacts
</Button>
)}
</div>
)}
</div>
)
})