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.
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { Check, Loader2, X } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
useAcceptEnrichmentSuggestion,
|
|
useRejectEnrichmentSuggestion,
|
|
useVisibleEnrichmentSuggestions,
|
|
} from "@/lib/api/hooks/use-contact-discovery"
|
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
|
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
|
import {
|
|
FIELD_LABELS,
|
|
profileDisplayName,
|
|
} from "@/lib/contacts/discovery-utils"
|
|
import type { ApiEnrichmentSuggestion } from "@/lib/contacts/discovery-types"
|
|
import type { ApiContact } from "@/lib/api/types"
|
|
import { contactApiPath } from "@/lib/contacts/contact-api-path"
|
|
import type { FullContact } from "@/lib/contacts/types"
|
|
import {
|
|
CONTACTS_HEADING_TEXT,
|
|
CONTACTS_MUTED_TEXT,
|
|
CONTACTS_DISCOVERY_FIELD_ROW_CLASS,
|
|
CONTACTS_PAGE_CARD_CLASS,
|
|
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
|
CONTACTS_PRIMARY_BTN_CLASS,
|
|
} from "@/lib/contacts-chrome-classes"
|
|
import {
|
|
DiscoveryCardsMasonry,
|
|
DiscoveryCardsMasonryItem,
|
|
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
|
|
import {
|
|
DiscoveryFieldChips,
|
|
type ChipFieldItem,
|
|
} from "@/components/gmail/contacts-page/discovery-field-chips"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const ACTION_BTN_CLASS = "h-9 rounded-full px-4 text-xs sm:text-sm"
|
|
|
|
export function AddCoordinatesView() {
|
|
const { suggestions, isLoading } = useVisibleEnrichmentSuggestions()
|
|
const acceptSuggestion = useAcceptEnrichmentSuggestion()
|
|
const rejectSuggestion = useRejectEnrichmentSuggestion()
|
|
const { contacts } = useContactsList()
|
|
const updateContact = useUpdateContact()
|
|
const [acceptingGroupKey, setAcceptingGroupKey] = useState<string | null>(null)
|
|
|
|
const grouped = useMemo(() => {
|
|
const map = new Map<string, ApiEnrichmentSuggestion[]>()
|
|
for (const s of suggestions) {
|
|
const key = s.target_contact_uid ?? s.profile_id ?? s.id
|
|
const list = map.get(key) ?? []
|
|
list.push(s)
|
|
map.set(key, list)
|
|
}
|
|
return [...map.entries()]
|
|
}, [suggestions])
|
|
|
|
async function handleAccept(suggestion: ApiEnrichmentSuggestion) {
|
|
if (suggestion.suggestion_type === "enrich_contact" && suggestion.target_contact_uid) {
|
|
const contact = contacts.find((c) => c.id === suggestion.target_contact_uid)
|
|
if (contact) {
|
|
const patch = buildContactPatch(contact, suggestion)
|
|
await updateContact.mutateAsync({
|
|
path: contactApiPath(contact),
|
|
etag: contact.etag,
|
|
contact: patch,
|
|
})
|
|
}
|
|
}
|
|
await acceptSuggestion.mutateAsync(suggestion.id)
|
|
}
|
|
|
|
async function handleAcceptAll(groupKey: string, group: ApiEnrichmentSuggestion[]) {
|
|
setAcceptingGroupKey(groupKey)
|
|
try {
|
|
const first = group[0]
|
|
if (first.suggestion_type === "enrich_contact" && first.target_contact_uid) {
|
|
const contact = contacts.find((c) => c.id === first.target_contact_uid)
|
|
if (contact) {
|
|
const patch = buildMergedContactPatch(contact, group)
|
|
await updateContact.mutateAsync({
|
|
path: contactApiPath(contact),
|
|
etag: contact.etag,
|
|
contact: patch,
|
|
})
|
|
}
|
|
}
|
|
for (const s of group) {
|
|
await acceptSuggestion.mutateAsync(s.id)
|
|
}
|
|
} finally {
|
|
setAcceptingGroupKey(null)
|
|
}
|
|
}
|
|
|
|
const busy = acceptSuggestion.isPending || updateContact.isPending
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
|
|
Ajouter des coordonnées ({suggestions.length})
|
|
</h3>
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
|
)}
|
|
|
|
{!isLoading && suggestions.length === 0 && (
|
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
|
Aucune suggestion disponible. Analysez vos e-mails depuis « Autres contacts » pour détecter des coordonnées dans les signatures.
|
|
</p>
|
|
)}
|
|
|
|
<DiscoveryCardsMasonry>
|
|
{grouped.map(([key, group]) => (
|
|
<DiscoveryCardsMasonryItem key={key}>
|
|
<SuggestionGroup
|
|
suggestions={group}
|
|
contacts={contacts}
|
|
onAccept={handleAccept}
|
|
onAcceptAll={() => handleAcceptAll(key, group)}
|
|
onReject={(id) => rejectSuggestion.mutate(id)}
|
|
busy={busy}
|
|
acceptingAll={acceptingGroupKey === key}
|
|
/>
|
|
</DiscoveryCardsMasonryItem>
|
|
))}
|
|
</DiscoveryCardsMasonry>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SuggestionGroup({
|
|
suggestions,
|
|
contacts,
|
|
onAccept,
|
|
onAcceptAll,
|
|
onReject,
|
|
busy,
|
|
acceptingAll,
|
|
}: {
|
|
suggestions: ApiEnrichmentSuggestion[]
|
|
contacts: FullContact[]
|
|
onAccept: (s: ApiEnrichmentSuggestion) => void
|
|
onAcceptAll: () => void
|
|
onReject: (id: string) => void
|
|
busy: boolean
|
|
acceptingAll: boolean
|
|
}) {
|
|
const first = suggestions[0]
|
|
const profile = first.profile
|
|
const existing = first.target_contact_uid
|
|
? contacts.find((c) => c.id === first.target_contact_uid)
|
|
: null
|
|
|
|
const title = existing
|
|
? `${existing.firstName} ${existing.lastName}`.trim() || existing.emails[0]?.value
|
|
: profile
|
|
? profileDisplayName(profile)
|
|
: "Contact suggéré"
|
|
|
|
const chipItems: ChipFieldItem[] = suggestions.map((s) => ({
|
|
id: s.id,
|
|
fieldKey: s.field_path,
|
|
value: s.suggested_value,
|
|
}))
|
|
|
|
return (
|
|
<div className={CONTACTS_PAGE_CARD_CLASS}>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className={cn("text-sm font-medium leading-tight", CONTACTS_HEADING_TEXT)}>{title}</p>
|
|
{first.suggestion_type === "enrich_contact" && (
|
|
<p className={cn("text-[11px]", CONTACTS_MUTED_TEXT)}>
|
|
Enrichissement · {suggestions.length} champ{suggestions.length > 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onAcceptAll}
|
|
disabled={busy}
|
|
className={ACTION_BTN_CLASS}
|
|
>
|
|
{acceptingAll ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Check className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Tout ajouter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DiscoveryFieldChips
|
|
items={chipItems}
|
|
dismissible
|
|
onRemove={(id) => onReject(id)}
|
|
/>
|
|
|
|
<details className="mt-2">
|
|
<summary className={cn("cursor-pointer text-[11px]", CONTACTS_MUTED_TEXT)}>
|
|
Gérer champ par champ
|
|
</summary>
|
|
<ul className="mt-2 space-y-1">
|
|
{suggestions.map((s) => (
|
|
<li
|
|
key={s.id}
|
|
className={CONTACTS_DISCOVERY_FIELD_ROW_CLASS}
|
|
>
|
|
<span className={cn("min-w-0 truncate text-xs", CONTACTS_HEADING_TEXT)}>
|
|
<span className={CONTACTS_MUTED_TEXT}>
|
|
{FIELD_LABELS[s.field_path] ?? s.field_path} :
|
|
</span>{" "}
|
|
{s.suggested_value}
|
|
</span>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => onReject(s.id)}
|
|
className="rounded-full p-1 text-muted-foreground hover:bg-accent"
|
|
aria-label="Ignorer"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => onAccept(s)}
|
|
disabled={busy}
|
|
className="h-7 rounded-full px-2.5 text-[11px]"
|
|
>
|
|
<Check className="mr-1 h-3 w-3" />
|
|
Accepter
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function buildContactPatch(
|
|
contact: FullContact,
|
|
suggestion: ApiEnrichmentSuggestion,
|
|
): Partial<ApiContact> {
|
|
const { field_path, suggested_value } = suggestion
|
|
switch (field_path) {
|
|
case "full_name":
|
|
return { full_name: suggested_value }
|
|
case "company":
|
|
return { org: suggested_value }
|
|
case "job_title":
|
|
return { raw_vcard: appendVCardField(contact, "TITLE", suggested_value) }
|
|
case "phones":
|
|
return { phone: suggested_value }
|
|
case "emails":
|
|
return { email: suggested_value }
|
|
case "social_profiles": {
|
|
const type = (suggestion.suggested_label || "other").toLowerCase()
|
|
return {
|
|
raw_vcard: appendVCardSocialProfile(contact, type, suggested_value),
|
|
}
|
|
}
|
|
default:
|
|
return { raw_vcard: appendVCardField(contact, field_path.toUpperCase(), suggested_value) }
|
|
}
|
|
}
|
|
|
|
function buildMergedContactPatch(
|
|
contact: FullContact,
|
|
suggestions: ApiEnrichmentSuggestion[],
|
|
): Partial<ApiContact> {
|
|
const patch: Partial<ApiContact> = {}
|
|
let vcardExtra = ""
|
|
|
|
for (const s of suggestions) {
|
|
const part = buildContactPatch(contact, s)
|
|
if (part.full_name) patch.full_name = part.full_name
|
|
if (part.org) patch.org = part.org
|
|
if (part.phone) patch.phone = part.phone
|
|
if (part.email) patch.email = part.email
|
|
if (part.raw_vcard && part.raw_vcard !== patch.raw_vcard) {
|
|
vcardExtra += part.raw_vcard.replace(/^BEGIN:VCARD[\s\S]*?VERSION:3\.0\n/, "").replace(/\nEND:VCARD$/, "\n")
|
|
}
|
|
}
|
|
|
|
if (vcardExtra) {
|
|
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
|
|
patch.raw_vcard = `${base}${vcardExtra}END:VCARD`
|
|
}
|
|
|
|
return patch
|
|
}
|
|
|
|
function appendVCardField(contact: FullContact, prop: string, value: string): string {
|
|
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
|
|
return `${base}${prop}:${value}\nEND:VCARD`
|
|
}
|
|
|
|
function appendVCardSocialProfile(contact: FullContact, type: string, value: string): string {
|
|
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
|
|
const socialType = type === "x" ? "twitter" : type
|
|
return `${base}X-SOCIALPROFILE;TYPE=${socialType}:${value}\nEND:VCARD`
|
|
}
|