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.
294 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
})
|