ultisuite-client/components/gmail/contacts-page/merge-duplicates-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

285 lines
9.1 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useMergeContactPair } from "@/lib/api/hooks/use-contact-mutations"
import { findDuplicatePairs, mergePairKey, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
import { useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
import { fullContactDisplayName, type MergeSuggestion } from "@/lib/contacts/types"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { AddCoordinatesView } from "./add-coordinates-view"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PAGE_INFO_BANNER_CLASS,
CONTACTS_PAGE_INFO_BANNER_ICON_CLASS,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PAGE_TAB_ACTIVE_CLASS,
CONTACTS_PAGE_TAB_INACTIVE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
type SubView = "merge" | "coordinates"
const REASON_LABELS: Record<DuplicateMatchReason, string> = {
email: "Même adresse e-mail",
phone: "Même numéro de téléphone",
name: "Nom similaire",
}
export function MergeDuplicatesView() {
const [subView, setSubView] = useState<SubView>("merge")
const { contacts, bookId } = useContactsList()
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
const mergeContactPairMutation = useMergeContactPair()
const mergeSuggestions = useMemo(
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
[contacts, ignoredMergePairs]
)
const { suggestions: coordinateSuggestions } = useVisibleEnrichmentSuggestions()
const [mergingAll, setMergingAll] = useState(false)
const [mergingPairKey, setMergingPairKey] = useState<string | null>(null)
function handleMerge(suggestion: MergeSuggestion) {
if (!bookId) {
toast.error("Carnet de contacts introuvable")
return
}
if (!suggestion.contactA.etag) {
toast.error("Impossible de fusionner : version du contact inconnue. Rechargez la liste.")
return
}
const pairKey = mergePairKey(suggestion.contactA.id, suggestion.contactB.id)
setMergingPairKey(pairKey)
mergeContactPairMutation.mutate(
{
bookId,
contactA: suggestion.contactA,
contactB: suggestion.contactB,
},
{
onSuccess: () => toast.success("Contacts fusionnés"),
onError: (err) => {
const msg =
err instanceof Error && err.message
? err.message
: "Impossible de fusionner ces contacts"
toast.error(msg)
},
onSettled: () => setMergingPairKey(null),
},
)
}
function handleIgnore(suggestion: MergeSuggestion) {
ignoreMergePair(suggestion.contactA.id, suggestion.contactB.id)
}
async function handleMergeAll() {
if (!bookId) {
toast.error("Carnet de contacts introuvable")
return
}
if (mergeSuggestions.length === 0) return
setMergingAll(true)
try {
for (const suggestion of mergeSuggestions) {
if (!suggestion.contactA.etag) continue
await mergeContactPairMutation.mutateAsync({
bookId,
contactA: suggestion.contactA,
contactB: suggestion.contactB,
})
}
toast.success("Doublons fusionnés")
} catch (err) {
const msg =
err instanceof Error && err.message
? err.message
: "Impossible de fusionner tous les doublons"
toast.error(msg)
} finally {
setMergingAll(false)
}
}
return (
<div className="px-6 py-6 text-foreground">
<div className={CONTACTS_PAGE_INFO_BANNER_CLASS}>
<div className={CONTACTS_PAGE_INFO_BANNER_ICON_CLASS}>
<span className="text-2xl">🧹</span>
</div>
<div>
<h2 className={cn("text-base font-medium", CONTACTS_HEADING_TEXT)}>
Des méthodes simples pour nettoyer vos contacts
</h2>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
Obtenez de l'aide pour fusionner les contacts en double, ajouter des informations utiles, et bien encore
</p>
</div>
</div>
<div className="mb-6 flex gap-2">
<button
type="button"
onClick={() => setSubView("merge")}
className={subView === "merge" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Fusionner les doublons
{mergeSuggestions.length > 0 && (
<span className="ml-2 text-xs">({mergeSuggestions.length})</span>
)}
</button>
<button
type="button"
onClick={() => setSubView("coordinates")}
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Ajouter des coordonnées
{coordinateSuggestions.length > 0 && (
<span className="ml-2 text-xs">({coordinateSuggestions.length})</span>
)}
</button>
</div>
{subView === "merge" && (
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Fusionner les doublons ({mergeSuggestions.length})
</h3>
{mergeSuggestions.length > 0 && (
<Button
onClick={handleMergeAll}
disabled={mergingAll}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{mergingAll ? "Fusion…" : "Tout fusionner"}
</Button>
)}
</div>
{mergeSuggestions.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucun doublon détecté
</p>
)}
<DiscoveryCardsMasonry>
{mergeSuggestions.map((suggestion) => {
const pairKey = mergePairKey(suggestion.contactA.id, suggestion.contactB.id)
const isMerging = mergingPairKey === pairKey
return (
<DiscoveryCardsMasonryItem key={pairKey}>
<MergeSuggestionCard
suggestion={suggestion}
merging={isMerging}
onMerge={() => handleMerge(suggestion)}
onIgnore={() => handleIgnore(suggestion)}
/>
</DiscoveryCardsMasonryItem>
)
})}
</DiscoveryCardsMasonry>
</div>
)}
{subView === "coordinates" && <AddCoordinatesView />}
</div>
)
}
function MergeSuggestionCard({
suggestion,
merging,
onMerge,
onIgnore,
}: {
suggestion: MergeSuggestion
merging: boolean
onMerge: () => void
onIgnore: () => void
}) {
const { contactA, contactB, reason } = suggestion
return (
<div className={cn(CONTACTS_PAGE_CARD_CLASS, "overflow-hidden")}>
<p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
{REASON_LABELS[reason]}
</p>
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
<ContactMiniCard contact={contactA} />
<ContactMiniCard contact={contactB} />
</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-3">
<button
type="button"
onClick={onIgnore}
disabled={merging}
className={CONTACTS_PAGE_LINK_BTN_CLASS}
>
Ignorer
</button>
<Button
onClick={onMerge}
disabled={merging}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{merging ? "Fusion…" : "Fusionner"}
</Button>
</div>
</div>
)
}
function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").FullContact }) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || "?"
return (
<div className="flex min-w-0 items-start gap-3 overflow-hidden">
<ContactAvatar contact={contact} name={name} size="sm" />
<div className="min-w-0 flex-1 overflow-hidden">
<p
className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}
title={name}
>
{name}
</p>
{contact.emails[0] && (
<p
className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}
title={contact.emails[0].value}
>
{contact.emails[0].value}
</p>
)}
{contact.phones[0] && (
<p
className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}
title={`${contact.phones[0].value} (${contact.phones[0].label})`}
>
{contact.phones[0].value} ({contact.phones[0].label})
</p>
)}
</div>
</div>
)
}