ultisuite-client/components/gmail/contacts-page/add-coordinates-view.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

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`
}