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.
285 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|