Add Contact Avatar Features and Improve UI Components
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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.
This commit is contained in:
parent
a4b548ca08
commit
07d57f13a8
@ -9,11 +9,10 @@ import {
|
|||||||
} from "@/components/ui/hover-card"
|
} from "@/components/ui/hover-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import {
|
import {
|
||||||
avatarColor,
|
|
||||||
cleanSenderName,
|
cleanSenderName,
|
||||||
resolveSenderEmail,
|
resolveSenderEmail,
|
||||||
senderInitial,
|
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -67,7 +66,6 @@ export function ContactHoverCard({
|
|||||||
|
|
||||||
const name = cleanSenderName(displayName)
|
const name = cleanSenderName(displayName)
|
||||||
const email = resolveSenderEmail(displayName, emailOverride)
|
const email = resolveSenderEmail(displayName, emailOverride)
|
||||||
const color = avatarColor(name)
|
|
||||||
|
|
||||||
const matchedContact = useMemo(
|
const matchedContact = useMemo(
|
||||||
() => findContactByEmail(contacts, email),
|
() => findContactByEmail(contacts, email),
|
||||||
@ -178,12 +176,12 @@ export function ContactHoverCard({
|
|||||||
>
|
>
|
||||||
<div className="p-4 pb-3">
|
<div className="p-4 pb-3">
|
||||||
<div className="relative flex items-start gap-3">
|
<div className="relative flex items-start gap-3">
|
||||||
<div
|
<ContactAvatar
|
||||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
|
contact={matchedContact}
|
||||||
style={{ backgroundColor: color }}
|
name={name}
|
||||||
>
|
email={email}
|
||||||
{senderInitial(name)}
|
size="md"
|
||||||
</div>
|
/>
|
||||||
<div className="min-w-0 flex-1 pr-8">
|
<div className="min-w-0 flex-1 pr-8">
|
||||||
<p className="truncate text-base font-semibold leading-tight text-[#202124]">{name}</p>
|
<p className="truncate text-base font-semibold leading-tight text-[#202124]">{name}</p>
|
||||||
<p className="truncate text-sm leading-tight text-[#5f6368]">{email}</p>
|
<p className="truncate text-sm leading-tight text-[#5f6368]">{email}</p>
|
||||||
|
|||||||
@ -1,23 +1,313 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Check, Loader2, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
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_MUTED_TEXT,
|
||||||
|
CONTACTS_DISCOVERY_FIELD_ROW_CLASS,
|
||||||
|
CONTACTS_PAGE_CARD_CLASS,
|
||||||
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} 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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ACTION_BTN_CLASS = "h-9 rounded-full px-4 text-xs sm:text-sm"
|
||||||
|
|
||||||
export function AddCoordinatesView() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
|
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
|
||||||
Ajouter des coordonnées (0)
|
Ajouter des coordonnées ({suggestions.length})
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
{isLoading && (
|
||||||
Aucune suggestion disponible
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
</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>
|
</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`
|
||||||
|
}
|
||||||
|
|||||||
148
components/gmail/contacts-page/blocked-contacts-view.tsx
Normal file
148
components/gmail/contacts-page/blocked-contacts-view.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { flushSync } from "react-dom"
|
||||||
|
import {
|
||||||
|
useAddDiscoveredContact,
|
||||||
|
useBlockedDiscoveredContacts,
|
||||||
|
useRejectDiscoveredProfile,
|
||||||
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
|
||||||
|
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
|
||||||
|
import {
|
||||||
|
CONTACTS_HEADING_TEXT,
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import {
|
||||||
|
DiscoveryCardsMasonry,
|
||||||
|
DiscoveryCardsMasonryItem,
|
||||||
|
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
|
||||||
|
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface BlockedContactsViewProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileToGroup(profile: ApiDiscoveredProfile): ApiDiscoveredProfileGroup {
|
||||||
|
return {
|
||||||
|
group_key: profile.id,
|
||||||
|
profile_ids: [profile.id],
|
||||||
|
display_name: profileDisplayName(profile),
|
||||||
|
primary_email: profile.primary_email,
|
||||||
|
message_count: profile.message_count,
|
||||||
|
profile,
|
||||||
|
profiles: [profile],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockedContactsView({ searchQuery }: BlockedContactsViewProps) {
|
||||||
|
const { bookId } = useContactsList()
|
||||||
|
const { data: profiles = [], isLoading } = useBlockedDiscoveredContacts()
|
||||||
|
const addDiscoveredContact = useAddDiscoveredContact()
|
||||||
|
const rejectProfile = useRejectDiscoveredProfile()
|
||||||
|
const unblockSender = useBlockedSendersStore((s) => s.unblockSender)
|
||||||
|
const [removedProfileIds, setRemovedProfileIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const markProfileRemoved = useCallback((profileId: string) => {
|
||||||
|
setRemovedProfileIds((prev) => {
|
||||||
|
if (prev.has(profileId)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(profileId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const restoreProfile = useCallback((profileId: string) => {
|
||||||
|
setRemovedProfileIds((prev) => {
|
||||||
|
if (!prev.has(profileId)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(profileId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const visible = profiles.filter((p) => !removedProfileIds.has(p.id))
|
||||||
|
const q = searchQuery.trim().toLowerCase()
|
||||||
|
if (!q) return visible
|
||||||
|
return visible.filter((p) => {
|
||||||
|
const name = profileDisplayName(p).toLowerCase()
|
||||||
|
return name.includes(q) || p.primary_email.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}, [profiles, searchQuery, removedProfileIds])
|
||||||
|
|
||||||
|
function handleAdd(profile: ApiDiscoveredProfile, buildContact: () => FullContact) {
|
||||||
|
if (!bookId) return
|
||||||
|
flushSync(() => markProfileRemoved(profile.id))
|
||||||
|
unblockSender(profile.primary_email)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
addDiscoveredContact.mutate(
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
profileId: profile.id,
|
||||||
|
contact: fullContactToApiContact(buildContact()),
|
||||||
|
},
|
||||||
|
{ onError: () => restoreProfile(profile.id) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(profile: ApiDiscoveredProfile) {
|
||||||
|
flushSync(() => markProfileRemoved(profile.id))
|
||||||
|
unblockSender(profile.primary_email)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rejectProfile.mutate(profile.id, { onError: () => restoreProfile(profile.id) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addingProfileId =
|
||||||
|
addDiscoveredContact.isPending && typeof addDiscoveredContact.variables?.profileId === "string"
|
||||||
|
? addDiscoveredContact.variables.profileId
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-6 text-foreground">
|
||||||
|
<h2 className={cn("mb-2 text-base font-medium", CONTACTS_HEADING_TEXT)}>Bloqués</h2>
|
||||||
|
<p className={cn("mb-6 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Ces expéditeurs sont traités comme indésirables : pas de suggestion, contenus distants
|
||||||
|
bloqués dans les e-mails. Vous pouvez les ajouter à votre carnet ou les supprimer
|
||||||
|
définitivement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className={cn("mb-4", CONTACTS_PAGE_SECTION_TITLE_CLASS)}>
|
||||||
|
{filtered.length} expéditeur{filtered.length !== 1 ? "s" : ""}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Aucun expéditeur bloqué.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DiscoveryCardsMasonry>
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<DiscoveryCardsMasonryItem key={p.id}>
|
||||||
|
<SuggestedContactCard
|
||||||
|
group={profileToGroup(p)}
|
||||||
|
mode="blocked"
|
||||||
|
addBusy={addingProfileId === p.id}
|
||||||
|
busy={rejectProfile.isPending && rejectProfile.variables === p.id}
|
||||||
|
onAdd={(buildContact) => handleAdd(p, buildContact)}
|
||||||
|
onRemove={() => handleRemove(p)}
|
||||||
|
/>
|
||||||
|
</DiscoveryCardsMasonryItem>
|
||||||
|
))}
|
||||||
|
</DiscoveryCardsMasonry>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -42,17 +42,17 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { contactApiPath } from "@/lib/contacts/contact-api-path"
|
||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS,
|
|
||||||
CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS,
|
|
||||||
CONTACTS_PAGE_ICON_BTN_CLASS,
|
CONTACTS_PAGE_ICON_BTN_CLASS,
|
||||||
CONTACTS_PAGE_SAVE_BTN_CLASS,
|
CONTACTS_PAGE_SAVE_BTN_CLASS,
|
||||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||||
@ -103,6 +103,7 @@ const contactFormSchema = z.object({
|
|||||||
birthday: z.object({ day: z.any().optional(), month: z.any().optional(), year: z.any().optional() }).optional(),
|
birthday: z.object({ day: z.any().optional(), month: z.any().optional(), year: z.any().optional() }).optional(),
|
||||||
notes: z.string().optional().default(""),
|
notes: z.string().optional().default(""),
|
||||||
labels: z.array(z.string()).optional().default([]),
|
labels: z.array(z.string()).optional().default([]),
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ContactFormValues = z.infer<typeof contactFormSchema>
|
type ContactFormValues = z.infer<typeof contactFormSchema>
|
||||||
@ -145,6 +146,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
addresses: [],
|
addresses: [],
|
||||||
birthday: { day: undefined, month: undefined, year: undefined },
|
birthday: { day: undefined, month: undefined, year: undefined },
|
||||||
notes: "", labels: [],
|
notes: "", labels: [],
|
||||||
|
avatarUrl: undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -175,6 +177,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined },
|
birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined },
|
||||||
notes: existingContact.notes ?? "",
|
notes: existingContact.notes ?? "",
|
||||||
labels: existingContact.labels ?? [],
|
labels: existingContact.labels ?? [],
|
||||||
|
avatarUrl: existingContact.avatarUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [existingContact, reset])
|
}, [existingContact, reset])
|
||||||
@ -182,6 +185,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
const firstName = watch("firstName")
|
const firstName = watch("firstName")
|
||||||
const lastName = watch("lastName")
|
const lastName = watch("lastName")
|
||||||
const watchedEmails = watch("emails")
|
const watchedEmails = watch("emails")
|
||||||
|
const avatarUrl = watch("avatarUrl")
|
||||||
const currentLabels = watch("labels") ?? []
|
const currentLabels = watch("labels") ?? []
|
||||||
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||||
|
|
||||||
@ -210,6 +214,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined,
|
birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined,
|
||||||
notes: data.notes || undefined,
|
notes: data.notes || undefined,
|
||||||
labels: data.labels?.length ? data.labels : undefined,
|
labels: data.labels?.length ? data.labels : undefined,
|
||||||
|
avatarUrl: data.avatarUrl || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@ -230,24 +235,43 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
onSaved(created?.uid ?? tempId)
|
onSaved(created?.uid ?? tempId)
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else if (contactId) {
|
} else if (contactId && existingContact) {
|
||||||
const fullContact: FullContact = {
|
const fullContact: FullContact = {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
|
path: existingContact.path,
|
||||||
|
etag: existingContact.etag,
|
||||||
...payload,
|
...payload,
|
||||||
firstName: payload.firstName ?? "",
|
firstName: payload.firstName ?? "",
|
||||||
lastName: payload.lastName ?? "",
|
lastName: payload.lastName ?? "",
|
||||||
emails: payload.emails ?? [],
|
emails: payload.emails ?? [],
|
||||||
phones: payload.phones ?? [],
|
phones: payload.phones ?? [],
|
||||||
createdAt: Date.now(),
|
createdAt: existingContact.createdAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
updateContactMutation.mutate({
|
if (!existingContact.etag) {
|
||||||
path: contactId,
|
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
|
||||||
contact: fullContactToApiContact(fullContact),
|
return
|
||||||
})
|
}
|
||||||
onSaved(contactId)
|
updateContactMutation.mutate(
|
||||||
|
{
|
||||||
|
path: contactApiPath(fullContact),
|
||||||
|
etag: existingContact.etag,
|
||||||
|
contact: fullContactToApiContact(fullContact),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => onSaved(contactId),
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,28 +298,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="mb-6 flex flex-col items-center">
|
<div className="mb-6 flex flex-col items-center">
|
||||||
{displayName ? (
|
<ContactAvatarPicker
|
||||||
<div className="relative">
|
variant="page"
|
||||||
<div
|
avatarUrl={avatarUrl}
|
||||||
className="flex h-28 w-28 items-center justify-center rounded-full text-4xl font-medium text-white"
|
displayName={displayName}
|
||||||
style={{ backgroundColor: avatarColor(displayName) }}
|
email={watchedEmails?.find((e) => e.value?.trim())?.value}
|
||||||
>
|
onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })}
|
||||||
{senderInitial(displayName)}
|
/>
|
||||||
</div>
|
|
||||||
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
<div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
|
|
||||||
<User className="h-12 w-12" />
|
|
||||||
</div>
|
|
||||||
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Download,
|
Download,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Mail,
|
Mail,
|
||||||
@ -19,8 +21,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
import { downloadContactVCard } from "@/lib/contacts/export-contacts"
|
import { downloadContactVCard } from "@/lib/contacts/export-contacts"
|
||||||
import {
|
import {
|
||||||
@ -34,6 +36,9 @@ import {
|
|||||||
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
|
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
|
||||||
|
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
|
||||||
|
|
||||||
const FRENCH_MONTHS = [
|
const FRENCH_MONTHS = [
|
||||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
@ -59,7 +64,10 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
||||||
const deleteContactMutation = useDeleteContact()
|
const deleteContactMutation = useDeleteContact()
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
const { data: llmSettings } = useLLMSettings()
|
||||||
|
const [improveOpen, setImproveOpen] = useState(false)
|
||||||
const contact = contacts.find((c) => c.id === contactId)
|
const contact = contacts.find((c) => c.id === contactId)
|
||||||
|
const llmReady = isLLMConfigured(llmSettings)
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
@ -71,8 +79,6 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
|
|
||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
||||||
const color = avatarColor(name)
|
|
||||||
const initial = senderInitial(name)
|
|
||||||
const primaryEmail = contact.emails[0]?.value
|
const primaryEmail = contact.emails[0]?.value
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -93,6 +99,22 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mr-1 hidden rounded-full sm:inline-flex"
|
||||||
|
onClick={() => setImproveOpen(true)}
|
||||||
|
disabled={!llmReady}
|
||||||
|
title={
|
||||||
|
llmReady
|
||||||
|
? undefined
|
||||||
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
|
||||||
|
Amélioration IA
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS}>
|
<Button variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS}>
|
||||||
<Star className="h-5 w-5" />
|
<Star className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -125,16 +147,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 pb-6">
|
<div className="flex items-center gap-6 pb-6">
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="xl" />
|
||||||
<img src={contact.avatarUrl} alt={name} className="h-24 w-24 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-24 w-24 items-center justify-center rounded-full text-3xl font-medium text-white"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className={cn("text-3xl", CONTACTS_HEADING_TEXT)}>{name}</h1>
|
<h1 className={cn("text-3xl", CONTACTS_HEADING_TEXT)}>{name}</h1>
|
||||||
{contact.company && (
|
{contact.company && (
|
||||||
@ -159,6 +172,25 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 sm:hidden">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full rounded-full"
|
||||||
|
onClick={() => setImproveOpen(true)}
|
||||||
|
disabled={!llmReady}
|
||||||
|
title={
|
||||||
|
llmReady
|
||||||
|
? undefined
|
||||||
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
|
||||||
|
Amélioration IA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{primaryEmail && (
|
{primaryEmail && (
|
||||||
<div className={cn("flex items-center gap-2 py-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
<div className={cn("flex items-center gap-2 py-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
||||||
<button type="button" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}>
|
<button type="button" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}>
|
||||||
@ -238,6 +270,12 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
|||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContactImproveDialog
|
||||||
|
contact={contact}
|
||||||
|
open={improveOpen}
|
||||||
|
onOpenChange={setImproveOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
139
components/gmail/contacts-page/contact-improve-dialog.tsx
Normal file
139
components/gmail/contacts-page/contact-improve-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Loader2, Sparkles } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useImproveContact } from "@/lib/api/hooks/use-improve-contact"
|
||||||
|
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import type { ApiEnrichedContactData } from "@/lib/contacts/discovery-types"
|
||||||
|
import {
|
||||||
|
fullContactToImprovePayload,
|
||||||
|
improvedDataToDraftFields,
|
||||||
|
mergeImprovedContact,
|
||||||
|
} from "@/lib/contacts/improve-contact"
|
||||||
|
import { contactApiPath } from "@/lib/contacts/contact-api-path"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import { DiscoveryFieldChips } from "@/components/gmail/contacts-page/discovery-field-chips"
|
||||||
|
import {
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ContactImproveDialogProps {
|
||||||
|
contact: FullContact
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactImproveDialog({ contact, open, onOpenChange }: ContactImproveDialogProps) {
|
||||||
|
const improveMutation = useImproveContact()
|
||||||
|
const updateMutation = useUpdateContact()
|
||||||
|
const [improved, setImproved] = useState<ApiEnrichedContactData | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setImproved(null)
|
||||||
|
improveMutation.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = fullContactToImprovePayload(contact)
|
||||||
|
improveMutation.mutate(payload, {
|
||||||
|
onSuccess: (data) => setImproved(data),
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : "Échec de l'amélioration IA"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- relancer à l'ouverture pour ce contact
|
||||||
|
}, [open, contact.id])
|
||||||
|
|
||||||
|
const loading = improveMutation.isPending
|
||||||
|
const chipItems = improved ? improvedDataToDraftFields(improved) : []
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
if (!improved) return
|
||||||
|
const merged = mergeImprovedContact(contact, improved)
|
||||||
|
const path = contactApiPath(contact)
|
||||||
|
if (!contact.etag) {
|
||||||
|
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ path, etag: contact.etag, contact: fullContactToApiContact(merged) },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Contact mis à jour")
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||||
|
Amélioration IA
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Analyse et nettoyage en cours…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && improveMutation.isError && (
|
||||||
|
<p className={cn("py-4 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
L'amélioration a échoué. Vérifiez la configuration LLM dans les réglages.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && improved && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Aperçu des informations nettoyées et réorganisées. Aucune donnée n'est
|
||||||
|
enregistrée tant que vous n'appliquez pas les changements.
|
||||||
|
</p>
|
||||||
|
<DiscoveryFieldChips items={chipItems} editable={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3 sm:gap-3">
|
||||||
|
<Button type="button" variant="link" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!improved || loading || updateMutation.isPending}
|
||||||
|
onClick={handleApply}
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Appliquer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
components/gmail/contacts-page/contact-label-picker-block.tsx
Normal file
162
components/gmail/contacts-page/contact-label-picker-block.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
|
||||||
|
import { Check, Minus, Plus } from "lucide-react"
|
||||||
|
import { Icon } from "@iconify/react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
|
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
|
||||||
|
import { LabelPickerLeadingVisual } from "@/components/gmail/email-label-picker-block"
|
||||||
|
|
||||||
|
export type ContactLabelPresence = "none" | "some" | "all"
|
||||||
|
|
||||||
|
export type ContactLabelPickerItemComponent = ComponentType<{
|
||||||
|
children: ReactNode
|
||||||
|
onSelect?: (event: Event) => void
|
||||||
|
className?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
function LabelPickerCheckboxVisual({
|
||||||
|
checked,
|
||||||
|
}: {
|
||||||
|
checked: boolean | "indeterminate"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex size-4 shrink-0 items-center justify-center rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent",
|
||||||
|
checked === true && "border-[#0b57d0] bg-[#0b57d0] text-white",
|
||||||
|
checked === "indeterminate" && "border-[#0b57d0] bg-[#0b57d0] text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked === true ? (
|
||||||
|
<Check className="size-3 stroke-[2.5] text-white" />
|
||||||
|
) : checked === "indeterminate" ? (
|
||||||
|
<Minus className="size-3 stroke-[2.5] text-white" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactLabelPickerBlock({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
labelRows,
|
||||||
|
resolveLabelVisual,
|
||||||
|
Item,
|
||||||
|
getLabelPresence,
|
||||||
|
onToggleLabel,
|
||||||
|
onCreateLabel,
|
||||||
|
listClassName,
|
||||||
|
searchAutoFocus = true,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
onQueryChange: (v: string) => void
|
||||||
|
labelRows: LabelRowItem[]
|
||||||
|
resolveLabelVisual: (labelId: string) => LabelPickerVisual
|
||||||
|
Item: ContactLabelPickerItemComponent
|
||||||
|
getLabelPresence: (labelId: string) => ContactLabelPresence
|
||||||
|
onToggleLabel: (labelId: string) => void
|
||||||
|
onCreateLabel: (labelText: string) => void
|
||||||
|
listClassName?: string
|
||||||
|
searchAutoFocus?: boolean
|
||||||
|
}) {
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!searchAutoFocus) return
|
||||||
|
let inner = 0
|
||||||
|
const outer = requestAnimationFrame(() => {
|
||||||
|
inner = requestAnimationFrame(() => {
|
||||||
|
searchInputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(outer)
|
||||||
|
if (inner) cancelAnimationFrame(inner)
|
||||||
|
}
|
||||||
|
}, [searchAutoFocus])
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const available = labelRows.filter((r) => r.enabled !== false)
|
||||||
|
const filtered = available.filter(
|
||||||
|
(row) => q.length === 0 || row.label.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
const trimmed = query.trim()
|
||||||
|
const hasExact = available.some(
|
||||||
|
(row) => row.label.toLowerCase() === trimmed.toLowerCase(),
|
||||||
|
)
|
||||||
|
const canCreate = trimmed.length > 0 && !hasExact
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-b border-[#eceff1] p-2"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder="Rechercher ou créer un libellé…"
|
||||||
|
aria-label="Rechercher ou créer un libellé"
|
||||||
|
className="h-8 border-[#dadce0] text-sm shadow-none"
|
||||||
|
autoComplete="off"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn("min-h-0 overflow-y-auto py-1", listClassName ?? "max-h-52")}>
|
||||||
|
{canCreate ? (
|
||||||
|
<Item
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onCreateLabel(trimmed)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
|
||||||
|
<span className="min-w-0 flex-1 text-[#0b57d0]">
|
||||||
|
Créer le libellé « {trimmed} »
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
{filtered.map((row) => {
|
||||||
|
const presence = getLabelPresence(row.id)
|
||||||
|
const boxChecked: boolean | "indeterminate" =
|
||||||
|
presence === "all" ? true : presence === "some" ? "indeterminate" : false
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={row.id}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggleLabel(row.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabelPickerCheckboxVisual checked={boxChecked} />
|
||||||
|
{row.icon ? (
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
<Icon
|
||||||
|
icon={row.icon}
|
||||||
|
className="size-[18px] shrink-0 text-[#5f6368]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<LabelPickerLeadingVisual visual={resolveLabelVisual(row.id)} />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{row.label}</span>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && !canCreate ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-[#5f6368]">
|
||||||
|
Aucun libellé correspondant
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -9,6 +9,9 @@ import { ContactsTable } from "./contacts-table"
|
|||||||
import { ContactDetailPage } from "./contact-detail-page"
|
import { ContactDetailPage } from "./contact-detail-page"
|
||||||
import { ContactCreatePage } from "./contact-create-page"
|
import { ContactCreatePage } from "./contact-create-page"
|
||||||
import { MergeDuplicatesView } from "./merge-duplicates-view"
|
import { MergeDuplicatesView } from "./merge-duplicates-view"
|
||||||
|
import { OtherContactsView } from "./other-contacts-view"
|
||||||
|
import { IgnoredContactsView } from "./ignored-contacts-view"
|
||||||
|
import { BlockedContactsView } from "./blocked-contacts-view"
|
||||||
import { TrashView } from "./trash-view"
|
import { TrashView } from "./trash-view"
|
||||||
import { BulkCreateDialog } from "./bulk-create-dialog"
|
import { BulkCreateDialog } from "./bulk-create-dialog"
|
||||||
import { ImportDialog } from "./import-dialog"
|
import { ImportDialog } from "./import-dialog"
|
||||||
@ -18,6 +21,8 @@ export type ContactsPageView =
|
|||||||
| "contacts"
|
| "contacts"
|
||||||
| "frequent"
|
| "frequent"
|
||||||
| "other"
|
| "other"
|
||||||
|
| "ignored"
|
||||||
|
| "blocked"
|
||||||
| "merge"
|
| "merge"
|
||||||
| "import"
|
| "import"
|
||||||
| "trash"
|
| "trash"
|
||||||
@ -139,7 +144,6 @@ export function ContactsAppShell() {
|
|||||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{(currentView === "contacts" ||
|
{(currentView === "contacts" ||
|
||||||
currentView === "frequent" ||
|
currentView === "frequent" ||
|
||||||
currentView === "other" ||
|
|
||||||
currentView === "label") && (
|
currentView === "label") && (
|
||||||
<ContactsTable
|
<ContactsTable
|
||||||
view={currentView}
|
view={currentView}
|
||||||
@ -148,6 +152,15 @@ export function ContactsAppShell() {
|
|||||||
onOpenContact={openContact}
|
onOpenContact={openContact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{currentView === "other" && (
|
||||||
|
<OtherContactsView searchQuery={searchQuery} />
|
||||||
|
)}
|
||||||
|
{currentView === "ignored" && (
|
||||||
|
<IgnoredContactsView searchQuery={searchQuery} />
|
||||||
|
)}
|
||||||
|
{currentView === "blocked" && (
|
||||||
|
<BlockedContactsView searchQuery={searchQuery} />
|
||||||
|
)}
|
||||||
{currentView === "detail" && activeContactId && (
|
{currentView === "detail" && activeContactId && (
|
||||||
<ContactDetailPage
|
<ContactDetailPage
|
||||||
contactId={activeContactId}
|
contactId={activeContactId}
|
||||||
|
|||||||
191
components/gmail/contacts-page/contacts-bulk-edit-dialog.tsx
Normal file
191
components/gmail/contacts-page/contacts-bulk-edit-dialog.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import {
|
||||||
|
CONTACT_BULK_EDIT_FIELDS,
|
||||||
|
collectBulkFieldSuggestions,
|
||||||
|
getContactBulkFieldValue,
|
||||||
|
type ContactBulkEditField,
|
||||||
|
} from "@/lib/contacts/bulk-edit-fields"
|
||||||
|
import {
|
||||||
|
CONTACTS_FIELD_CLASS,
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ContactsBulkEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
contacts: FullContact[]
|
||||||
|
onApply: (field: ContactBulkEditField, value: string) => void
|
||||||
|
isApplying?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsBulkEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
contacts,
|
||||||
|
onApply,
|
||||||
|
isApplying = false,
|
||||||
|
}: ContactsBulkEditDialogProps) {
|
||||||
|
const [field, setField] = useState<ContactBulkEditField>("company")
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
|
||||||
|
const suggestions = useMemo(
|
||||||
|
() => collectBulkFieldSuggestions(contacts, field),
|
||||||
|
[contacts, field],
|
||||||
|
)
|
||||||
|
|
||||||
|
const mixedValues = useMemo(() => {
|
||||||
|
const values = new Set(
|
||||||
|
contacts.map((c) => getContactBulkFieldValue(c, field)).filter(Boolean),
|
||||||
|
)
|
||||||
|
return values.size > 1
|
||||||
|
}, [contacts, field])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setField("company")
|
||||||
|
setValue("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suggestions.length === 1) {
|
||||||
|
setValue(suggestions[0]!)
|
||||||
|
} else {
|
||||||
|
setValue("")
|
||||||
|
}
|
||||||
|
}, [open, field, suggestions])
|
||||||
|
|
||||||
|
const isNotes = field === "notes"
|
||||||
|
const canApply = contacts.length > 0
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
if (!canApply) return
|
||||||
|
onApply(field, value)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Édition de masse</DialogTitle>
|
||||||
|
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{contacts.length} contact{contacts.length > 1 ? "s" : ""} sélectionné
|
||||||
|
{contacts.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Propriété
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={field}
|
||||||
|
onValueChange={(v) => setField(v as ContactBulkEditField)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={CONTACTS_FIELD_CLASS}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONTACT_BULK_EDIT_FIELDS.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Nouvelle valeur
|
||||||
|
</label>
|
||||||
|
{isNotes ? (
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mixedValues ? "Valeurs mixtes dans la sélection…" : "Notes…"
|
||||||
|
}
|
||||||
|
className={cn(CONTACTS_FIELD_CLASS, "min-h-24 resize-y")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mixedValues
|
||||||
|
? "Valeurs mixtes dans la sélection…"
|
||||||
|
: `Nouvelle valeur…`
|
||||||
|
}
|
||||||
|
className={CONTACTS_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Valeurs présentes dans la sélection
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue(suggestion)}
|
||||||
|
className={cn(
|
||||||
|
"max-w-full truncate rounded-full border border-border px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted",
|
||||||
|
value === suggestion && "border-primary bg-primary/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
disabled={!canApply || isApplying}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
components/gmail/contacts-page/contacts-bulk-merge-dialog.tsx
Normal file
136
components/gmail/contacts-page/contacts-bulk-merge-dialog.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
|
import { mergeManyContacts, pickContactMergePrimary } from "@/lib/contacts/merge-contacts"
|
||||||
|
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
|
||||||
|
import {
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ContactsBulkMergeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
contacts: FullContact[]
|
||||||
|
onMerge: (primaryId: string) => void
|
||||||
|
isMerging?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactListLabel(contact: FullContact): string {
|
||||||
|
return (
|
||||||
|
fullContactDisplayName(contact) ||
|
||||||
|
contact.emails[0]?.value ||
|
||||||
|
contact.phones[0]?.value ||
|
||||||
|
"Contact sans nom"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsBulkMergeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
contacts,
|
||||||
|
onMerge,
|
||||||
|
isMerging = false,
|
||||||
|
}: ContactsBulkMergeDialogProps) {
|
||||||
|
const defaultPrimary = useMemo(() => pickContactMergePrimary(contacts), [contacts])
|
||||||
|
const [primaryId, setPrimaryId] = useState(defaultPrimary?.id ?? "")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setPrimaryId(defaultPrimary?.id ?? contacts[0]?.id ?? "")
|
||||||
|
}, [open, defaultPrimary, contacts])
|
||||||
|
|
||||||
|
const preview = useMemo(
|
||||||
|
() => (primaryId ? mergeManyContacts(contacts, primaryId) : null),
|
||||||
|
[contacts, primaryId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const canMerge = contacts.length >= 2 && !!primaryId && !!preview?.primary.etag
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fusionner les contacts</DialogTitle>
|
||||||
|
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{contacts.length} contacts sélectionnés → 1 contact. Les autres seront
|
||||||
|
supprimés après fusion.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">Contact principal</p>
|
||||||
|
<RadioGroup value={primaryId} onValueChange={setPrimaryId} className="gap-2">
|
||||||
|
{contacts.map((contact) => {
|
||||||
|
const label = contactListLabel(contact)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={contact.id}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-3 rounded-lg border border-border px-3 py-2 transition-colors hover:bg-muted/50",
|
||||||
|
primaryId === contact.id && "border-primary bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem value={contact.id} id={`merge-primary-${contact.id}`} />
|
||||||
|
<ContactAvatar contact={contact} size="xs" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm text-foreground">{label}</span>
|
||||||
|
{contact.emails[0]?.value && (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{contact.emails[0].value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className={cn("rounded-lg border border-border px-3 py-2 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
<p className="font-medium text-foreground">Résultat fusionné</p>
|
||||||
|
<ul className="mt-1.5 space-y-0.5">
|
||||||
|
<li>{preview.merged.emails.length} e-mail{preview.merged.emails.length > 1 ? "s" : ""}</li>
|
||||||
|
<li>{preview.merged.phones.length} téléphone{preview.merged.phones.length > 1 ? "s" : ""}</li>
|
||||||
|
{(preview.merged.labels?.length ?? 0) > 0 && (
|
||||||
|
<li>{preview.merged.labels!.length} libellé{(preview.merged.labels!.length > 1 ? "s" : "")}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!preview?.primary.etag && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Version du contact principal inconnue. Rechargez la liste avant de fusionner.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
disabled={!canMerge || isMerging}
|
||||||
|
onClick={() => onMerge(primaryId)}
|
||||||
|
>
|
||||||
|
Fusionner
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -61,7 +61,10 @@ export function ContactsHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HeaderAccountActions className="shrink-0 pl-1 sm:pl-4" />
|
<HeaderAccountActions
|
||||||
|
className="shrink-0 pl-1 sm:pl-4"
|
||||||
|
settingsHref="/mail/settings/accounts"
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useDeferredValue, useMemo, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Clock,
|
Clock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Ban,
|
||||||
|
EyeOff,
|
||||||
Merge,
|
Merge,
|
||||||
Upload,
|
Upload,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -35,6 +37,7 @@ import {
|
|||||||
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
|
||||||
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
import type { ContactsPageView } from "./contacts-app-shell"
|
import type { ContactsPageView } from "./contacts-app-shell"
|
||||||
@ -67,11 +70,19 @@ export function ContactsSidebar({
|
|||||||
onSelectLabel,
|
onSelectLabel,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const { contacts } = useContactsList()
|
const { contacts } = useContactsList()
|
||||||
|
const deferredContacts = useDeferredValue(contacts)
|
||||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||||
const mergeSuggestionCount = useMemo(
|
const { data: discoveryCounts } = useDiscoveryCounts()
|
||||||
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)).length,
|
const mergeSuggestionCount = useMemo(() => {
|
||||||
[contacts, ignoredMergePairs]
|
if (deferredContacts.length < 2) return 0
|
||||||
)
|
return findDuplicatePairs(deferredContacts, new Set(ignoredMergePairs)).length
|
||||||
|
}, [deferredContacts, ignoredMergePairs])
|
||||||
|
const { suggestions: coordinateSuggestions } = useVisibleEnrichmentSuggestions()
|
||||||
|
const otherContactsCount = discoveryCounts?.other_contacts ?? 0
|
||||||
|
const ignoredCount = discoveryCounts?.ignored ?? 0
|
||||||
|
const blockedCount = discoveryCounts?.blocked ?? 0
|
||||||
|
const coordinatesCount = coordinateSuggestions.length
|
||||||
|
const mergeBadgeCount = mergeSuggestionCount + coordinatesCount
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
|
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
|
||||||
const [labelInput, setLabelInput] = useState("")
|
const [labelInput, setLabelInput] = useState("")
|
||||||
@ -189,9 +200,24 @@ export function ContactsSidebar({
|
|||||||
<NavItem
|
<NavItem
|
||||||
icon={<UserPlus className="h-5 w-5" />}
|
icon={<UserPlus className="h-5 w-5" />}
|
||||||
label="Autres contacts"
|
label="Autres contacts"
|
||||||
|
badge={otherContactsCount > 0 ? otherContactsCount : undefined}
|
||||||
active={currentView === "other"}
|
active={currentView === "other"}
|
||||||
onClick={() => onNavigate("other")}
|
onClick={() => onNavigate("other")}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
icon={<Ban className="h-5 w-5" />}
|
||||||
|
label="Bloqués"
|
||||||
|
count={blockedCount > 0 ? blockedCount : undefined}
|
||||||
|
active={currentView === "blocked"}
|
||||||
|
onClick={() => onNavigate("blocked")}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
icon={<EyeOff className="h-5 w-5" />}
|
||||||
|
label="Ignorés"
|
||||||
|
count={ignoredCount > 0 ? ignoredCount : undefined}
|
||||||
|
active={currentView === "ignored"}
|
||||||
|
onClick={() => onNavigate("ignored")}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="my-2 border-t border-border" />
|
<div className="my-2 border-t border-border" />
|
||||||
|
|
||||||
@ -200,7 +226,7 @@ export function ContactsSidebar({
|
|||||||
<NavItem
|
<NavItem
|
||||||
icon={<Merge className="h-5 w-5" />}
|
icon={<Merge className="h-5 w-5" />}
|
||||||
label="Fusionner et corriger"
|
label="Fusionner et corriger"
|
||||||
badge={mergeSuggestionCount > 0 ? mergeSuggestionCount : undefined}
|
badge={mergeBadgeCount > 0 ? mergeBadgeCount : undefined}
|
||||||
active={currentView === "merge"}
|
active={currentView === "merge"}
|
||||||
onClick={() => onNavigate("merge")}
|
onClick={() => onNavigate("merge")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,24 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react"
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from "react"
|
||||||
import { Printer, Download, MoreVertical, Trash2 } from "lucide-react"
|
import { Printer, Download, MoreVertical, Trash2, Tag, Ban, Pencil, GitMerge } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useDeleteContact, useMergeManyContacts } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
||||||
import { printContacts } from "@/lib/contacts/print-contacts"
|
import { printContacts } from "@/lib/contacts/print-contacts"
|
||||||
import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/export-contacts"
|
import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/export-contacts"
|
||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
import {
|
import {
|
||||||
contactsTableGridStyle,
|
contactsTableGridStyle,
|
||||||
@ -33,9 +37,18 @@ import {
|
|||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_TABLE_HEADER_CLASS,
|
CONTACTS_TABLE_HEADER_CLASS,
|
||||||
CONTACTS_TABLE_ROW_CLASS,
|
CONTACTS_TABLE_ROW_CLASS,
|
||||||
|
CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS,
|
||||||
|
CONTACTS_TABLE_TOOLBAR_CLASS,
|
||||||
|
CONTACTS_TABLE_STICKY_HEAD_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
|
||||||
|
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
|
||||||
|
import { ContactsBulkEditDialog } from "@/components/gmail/contacts-page/contacts-bulk-edit-dialog"
|
||||||
|
import { ContactsBulkMergeDialog } from "@/components/gmail/contacts-page/contacts-bulk-merge-dialog"
|
||||||
|
import { useContactBulkActions } from "@/components/gmail/contacts-page/use-contact-bulk-actions"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
|
const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
|
||||||
"name",
|
"name",
|
||||||
@ -45,6 +58,9 @@ const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
|
|||||||
"labels",
|
"labels",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const CONTACTS_ROW_CHECKBOX_HIT_CLASS =
|
||||||
|
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-muted/60 -m-1"
|
||||||
|
|
||||||
interface ContactsTableProps {
|
interface ContactsTableProps {
|
||||||
view: ContactsPageView
|
view: ContactsPageView
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@ -55,10 +71,24 @@ interface ContactsTableProps {
|
|||||||
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
|
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
|
||||||
const { visibleColumns, columnLabels } = useContactsTableColumns()
|
const { visibleColumns, columnLabels } = useContactsTableColumns()
|
||||||
const gridStyle = contactsTableGridStyle(visibleColumns)
|
const gridStyle = contactsTableGridStyle(visibleColumns)
|
||||||
const { contacts } = useContactsList()
|
const { contacts, bookId, isLoading, isError, error, refetch } = useContactsList()
|
||||||
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
||||||
const deleteContactMutation = useDeleteContact()
|
const deleteContactMutation = useDeleteContact()
|
||||||
|
const mergeManyContactsMutation = useMergeManyContacts()
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||||
|
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
||||||
|
const [bulkEditOpen, setBulkEditOpen] = useState(false)
|
||||||
|
const [bulkMergeOpen, setBulkMergeOpen] = useState(false)
|
||||||
|
const lastSelectionAnchorIdRef = useRef<string | null>(null)
|
||||||
|
const {
|
||||||
|
getLabelPresence,
|
||||||
|
toggleLabelOnContacts,
|
||||||
|
createAndApplyLabel,
|
||||||
|
blockContacts,
|
||||||
|
applyBulkField,
|
||||||
|
resolveLabelVisualById,
|
||||||
|
isUpdating,
|
||||||
|
} = useContactBulkActions()
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let list = contacts
|
let list = contacts
|
||||||
@ -91,6 +121,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
[filteredContacts]
|
[filteredContacts]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const filteredContactIds = useMemo(
|
||||||
|
() => filteredContacts.map((c) => c.id),
|
||||||
|
[filteredContacts],
|
||||||
|
)
|
||||||
|
|
||||||
const selectedContacts = useMemo(
|
const selectedContacts = useMemo(
|
||||||
() => filteredContacts.filter((c) => selectedIds.has(c.id)),
|
() => filteredContacts.filter((c) => selectedIds.has(c.id)),
|
||||||
[filteredContacts, selectedIds]
|
[filteredContacts, selectedIds]
|
||||||
@ -105,6 +140,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
lastSelectionAnchorIdRef.current = null
|
||||||
}, [view, activeLabelId])
|
}, [view, activeLabelId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -115,6 +151,10 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
}, [filteredIds])
|
}, [filteredIds])
|
||||||
|
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
const availableLabelRows = useMemo(
|
||||||
|
() => labelRows.filter((r) => r.enabled !== false),
|
||||||
|
[labelRows],
|
||||||
|
)
|
||||||
const activeLabelName = activeLabelId
|
const activeLabelName = activeLabelId
|
||||||
? labelRows.find((l) => l.id === activeLabelId)?.label
|
? labelRows.find((l) => l.id === activeLabelId)?.label
|
||||||
: null
|
: null
|
||||||
@ -134,13 +174,69 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
else next.delete(id)
|
else next.delete(id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
lastSelectionAnchorIdRef.current = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleContactInSelection(id: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
lastSelectionAnchorIdRef.current = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRangeInclusive(fromId: string, toId: string) {
|
||||||
|
const i0 = filteredContactIds.indexOf(fromId)
|
||||||
|
const i1 = filteredContactIds.indexOf(toId)
|
||||||
|
if (i0 === -1 || i1 === -1) return
|
||||||
|
const lo = Math.min(i0, i1)
|
||||||
|
const hi = Math.max(i0, i1)
|
||||||
|
setSelectedIds(new Set(filteredContactIds.slice(lo, hi + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShiftSelection(id: string) {
|
||||||
|
const anchor = lastSelectionAnchorIdRef.current
|
||||||
|
if (anchor == null) {
|
||||||
|
setSelectedIds(new Set([id]))
|
||||||
|
} else {
|
||||||
|
selectRangeInclusive(anchor, id)
|
||||||
|
}
|
||||||
|
lastSelectionAnchorIdRef.current = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContactRowClick(
|
||||||
|
id: string,
|
||||||
|
e: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">,
|
||||||
|
) {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleShiftSelection(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleContactInSelection(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenContact(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContactCheckboxClickCapture(id: string, e: MouseEvent) {
|
||||||
|
if (!e.shiftKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleShiftSelection(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll(checked: boolean) {
|
function toggleSelectAll(checked: boolean) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedIds(new Set(filteredContacts.map((c) => c.id)))
|
setSelectedIds(new Set(filteredContacts.map((c) => c.id)))
|
||||||
|
lastSelectionAnchorIdRef.current = filteredContacts[0]?.id ?? null
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
lastSelectionAnchorIdRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +249,36 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMergeSelected(primaryId: string) {
|
||||||
|
if (!bookId) {
|
||||||
|
toast.error("Carnet de contacts introuvable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedContacts.length < 2) return
|
||||||
|
|
||||||
|
mergeManyContactsMutation.mutate(
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
contacts: selectedContacts,
|
||||||
|
primaryId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Contacts fusionnés")
|
||||||
|
setBulkMergeOpen(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error && err.message
|
||||||
|
? err.message
|
||||||
|
: "Impossible de fusionner ces contacts"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function handleExportVcf() {
|
function handleExportVcf() {
|
||||||
if (selectionCount === 0) return
|
if (selectionCount === 0) return
|
||||||
const filename =
|
const filename =
|
||||||
@ -173,16 +299,93 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-4 sm:px-6">
|
<div className="px-3 py-4 sm:px-6">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className={CONTACTS_TABLE_STICKY_HEAD_CLASS}>
|
||||||
<div className="min-w-0">
|
<div className={CONTACTS_TABLE_TOOLBAR_CLASS}>
|
||||||
<h1 className={cn("truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>{viewTitle}</h1>
|
<h1 className={cn("min-w-0 flex-1 truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>
|
||||||
|
{viewTitle}
|
||||||
|
</h1>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{selectionCount > 0 && (
|
{selectionCount > 0 && (
|
||||||
<p className={cn("mt-0.5 text-sm", CONTACTS_MUTED_TEXT)}>
|
<span
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"mr-1 hidden text-sm whitespace-nowrap sm:inline",
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
|
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{selectionCount > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
|
||||||
|
onClick={() => setBulkEditOpen(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1.5 h-4 w-4" />
|
||||||
|
Édition de masse
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
|
||||||
|
disabled={selectionCount < 2}
|
||||||
|
onClick={() => setBulkMergeOpen(true)}
|
||||||
|
>
|
||||||
|
<GitMerge className="mr-1.5 h-4 w-4" />
|
||||||
|
Fusionner
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setLabelPickerQuery("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
|
||||||
|
aria-label="Ajouter ou retirer des libellés"
|
||||||
|
>
|
||||||
|
<Tag className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className={cn(
|
||||||
|
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
||||||
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ContactLabelPickerBlock
|
||||||
|
query={labelPickerQuery}
|
||||||
|
onQueryChange={setLabelPickerQuery}
|
||||||
|
labelRows={availableLabelRows}
|
||||||
|
resolveLabelVisual={resolveLabelVisualById}
|
||||||
|
Item={DropdownMenuItem}
|
||||||
|
getLabelPresence={(labelId) =>
|
||||||
|
getLabelPresence(selectedContacts, labelId)
|
||||||
|
}
|
||||||
|
onToggleLabel={(labelId) =>
|
||||||
|
toggleLabelOnContacts(selectedContacts, labelId)
|
||||||
|
}
|
||||||
|
onCreateLabel={(labelText) => {
|
||||||
|
createAndApplyLabel(selectedContacts, labelText)
|
||||||
|
setLabelPickerQuery("")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -241,7 +444,57 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
<MoreVertical className="h-5 w-5" />
|
<MoreVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className={cn("w-48", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
<DropdownMenuContent align="end" className={cn("w-56 overflow-visible", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
||||||
|
{selectionCount > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
|
||||||
|
<Tag className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
Ajouter / Retirer des libellés
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
className={cn(
|
||||||
|
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
||||||
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ContactLabelPickerBlock
|
||||||
|
query={labelPickerQuery}
|
||||||
|
onQueryChange={setLabelPickerQuery}
|
||||||
|
labelRows={availableLabelRows}
|
||||||
|
resolveLabelVisual={resolveLabelVisualById}
|
||||||
|
Item={DropdownMenuItem}
|
||||||
|
getLabelPresence={(labelId) =>
|
||||||
|
getLabelPresence(selectedContacts, labelId)
|
||||||
|
}
|
||||||
|
onToggleLabel={(labelId) =>
|
||||||
|
toggleLabelOnContacts(selectedContacts, labelId)
|
||||||
|
}
|
||||||
|
onCreateLabel={(labelText) => {
|
||||||
|
createAndApplyLabel(selectedContacts, labelText)
|
||||||
|
setLabelPickerQuery("")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuItem onClick={() => setBulkEditOpen(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
Édition de masse
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setBulkMergeOpen(true)}
|
||||||
|
disabled={selectionCount < 2}
|
||||||
|
>
|
||||||
|
<GitMerge className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
Fusionner
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => blockContacts(selectedContacts)}>
|
||||||
|
<Ban className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
Bloquer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => toggleSelectAll(true)}
|
onClick={() => toggleSelectAll(true)}
|
||||||
disabled={filteredContacts.length === 0}
|
disabled={filteredContacts.length === 0}
|
||||||
@ -257,26 +510,29 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={CONTACTS_TABLE_HEADER_CLASS}
|
className={CONTACTS_TABLE_HEADER_CLASS}
|
||||||
style={gridStyle}
|
style={gridStyle}
|
||||||
>
|
>
|
||||||
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
||||||
<span className="flex items-center justify-center">
|
<span className={CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
|
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
|
||||||
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
|
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
|
||||||
aria-label="Tout sélectionner"
|
aria-label="Tout sélectionner"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{DATA_COLUMNS.map((column) =>
|
{DATA_COLUMNS.map((column) =>
|
||||||
isContactsColumnVisible(visibleColumns, column) ? (
|
isContactsColumnVisible(visibleColumns, column) ? (
|
||||||
<span key={column}>{columnLabels[column]}</span>
|
<span key={column} className="flex min-h-8 items-center">
|
||||||
) : null
|
{columnLabels[column]}
|
||||||
)}
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredContacts.map((contact) => (
|
{filteredContacts.map((contact) => (
|
||||||
@ -287,15 +543,41 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
gridStyle={gridStyle}
|
gridStyle={gridStyle}
|
||||||
selected={selectedIds.has(contact.id)}
|
selected={selectedIds.has(contact.id)}
|
||||||
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
|
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
|
||||||
onOpen={() => onOpenContact(contact.id)}
|
onRowClick={(e) => handleContactRowClick(contact.id, e)}
|
||||||
|
onCheckboxClickCapture={(e) => handleContactCheckboxClickCapture(contact.id, e)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filteredContacts.length === 0 && (
|
{(isLoading || isError) && (
|
||||||
|
<ContactsLoadState
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && filteredContacts.length === 0 && (
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
Aucun contact trouvé
|
Aucun contact trouvé
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ContactsBulkEditDialog
|
||||||
|
open={bulkEditOpen}
|
||||||
|
onOpenChange={setBulkEditOpen}
|
||||||
|
contacts={selectedContacts}
|
||||||
|
onApply={(field, value) => applyBulkField(selectedContacts, field, value)}
|
||||||
|
isApplying={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContactsBulkMergeDialog
|
||||||
|
open={bulkMergeOpen}
|
||||||
|
onOpenChange={setBulkMergeOpen}
|
||||||
|
contacts={selectedContacts}
|
||||||
|
onMerge={handleMergeSelected}
|
||||||
|
isMerging={mergeManyContactsMutation.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -311,42 +593,43 @@ function ContactTableRow({
|
|||||||
gridStyle,
|
gridStyle,
|
||||||
selected,
|
selected,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onOpen,
|
onRowClick,
|
||||||
|
onCheckboxClickCapture,
|
||||||
}: {
|
}: {
|
||||||
contact: FullContact
|
contact: FullContact
|
||||||
visibleColumns: ContactsTableColumn[]
|
visibleColumns: ContactsTableColumn[]
|
||||||
gridStyle: CSSProperties
|
gridStyle: CSSProperties
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onToggleSelect: (checked: boolean) => void
|
onToggleSelect: (checked: boolean) => void
|
||||||
onOpen: () => void
|
onRowClick: (e: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">) => void
|
||||||
|
onCheckboxClickCapture: (e: MouseEvent<HTMLSpanElement>) => void
|
||||||
}) {
|
}) {
|
||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
||||||
const color = avatarColor(name)
|
|
||||||
const initial = senderInitial(name)
|
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onOpen}
|
onClick={onRowClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key !== "Enter" && e.key !== " ") return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onOpen()
|
onRowClick(e)
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
CONTACTS_TABLE_ROW_CLASS,
|
CONTACTS_TABLE_ROW_CLASS,
|
||||||
selected && "bg-mail-nav-selected"
|
"cursor-pointer",
|
||||||
|
selected && "bg-mail-nav-selected",
|
||||||
)}
|
)}
|
||||||
style={gridStyle}
|
style={gridStyle}
|
||||||
>
|
>
|
||||||
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
||||||
<span
|
<span
|
||||||
className="flex items-center justify-center"
|
className={CONTACTS_ROW_CHECKBOX_HIT_CLASS}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onClickCapture={onCheckboxClickCapture}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -359,16 +642,7 @@ function ContactTableRow({
|
|||||||
|
|
||||||
{isContactsColumnVisible(visibleColumns, "name") && (
|
{isContactsColumnVisible(visibleColumns, "name") && (
|
||||||
<span className="flex min-w-0 items-center gap-2 sm:gap-3">
|
<span className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="xs" />
|
||||||
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 shrink-0 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-foreground">{name}</span>
|
<span className="block truncate text-foreground">{name}</span>
|
||||||
{!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && (
|
{!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && (
|
||||||
|
|||||||
103
components/gmail/contacts-page/discovery-cards-masonry.tsx
Normal file
103
components/gmail/contacts-page/discovery-cards-masonry.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from "react"
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react"
|
||||||
|
import {
|
||||||
|
CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS,
|
||||||
|
CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS,
|
||||||
|
CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { useEnteringItemKeys } from "@/components/gmail/contacts-page/use-entering-item-keys"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface DiscoveryCardsMasonryProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
footer?: ReactNode
|
||||||
|
animateEnter?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function childKey(child: ReactNode, index: number): string {
|
||||||
|
if (isValidElement(child) && child.key != null) {
|
||||||
|
return String(child.key)
|
||||||
|
}
|
||||||
|
return String(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une seule liste DOM — grille responsive 1 col / 2 cols lg+. */
|
||||||
|
export function DiscoveryCardsMasonry({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
footer,
|
||||||
|
animateEnter = true,
|
||||||
|
}: DiscoveryCardsMasonryProps) {
|
||||||
|
const items = Children.toArray(children)
|
||||||
|
const enteringKeys = useEnteringItemKeys(items)
|
||||||
|
const [gridRef] = useAutoAnimate({ duration: 140, easing: "ease-out" })
|
||||||
|
|
||||||
|
const renderedItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.map((child, index) => {
|
||||||
|
const key = childKey(child, index)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0",
|
||||||
|
animateEnter && enteringKeys.has(key) && CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[items, enteringKeys, animateEnter],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS, className)}>
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
{renderedItems}
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoveryCardsMasonryItem({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return <div className={cn("min-w-0", className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoveryCardsMasonrySentinel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sentinelRef,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode
|
||||||
|
className?: string
|
||||||
|
sentinelRef?: RefObject<HTMLDivElement | null>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className={cn(CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
components/gmail/contacts-page/discovery-field-chips.tsx
Normal file
220
components/gmail/contacts-page/discovery-field-chips.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Pencil, X } from "lucide-react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { FIELD_LABELS } from "@/lib/contacts/discovery-utils"
|
||||||
|
import {
|
||||||
|
CONTACTS_DISCOVERY_CHIP_CLASS,
|
||||||
|
CONTACTS_DISCOVERY_GRID_CELL_CLASS,
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface ChipFieldItem {
|
||||||
|
id: string
|
||||||
|
fieldKey: string
|
||||||
|
value: string
|
||||||
|
removed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_GROUP_ORDER = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"full_name",
|
||||||
|
"company",
|
||||||
|
"department",
|
||||||
|
"job_title",
|
||||||
|
"emails",
|
||||||
|
"phones",
|
||||||
|
"addresses",
|
||||||
|
"website",
|
||||||
|
"social_profiles",
|
||||||
|
"notes",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function fieldSortIndex(fieldKey: string): number {
|
||||||
|
const i = FIELD_GROUP_ORDER.indexOf(fieldKey as (typeof FIELD_GROUP_ORDER)[number])
|
||||||
|
return i === -1 ? 999 : i
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFieldKeys(keys: string[]): string[] {
|
||||||
|
return [...keys].sort(
|
||||||
|
(a, b) => fieldSortIndex(a) - fieldSortIndex(b) || a.localeCompare(b, "fr"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipAccessibleLabel(fieldLabel: string, value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
return v ? `${fieldLabel} : ${v}` : fieldLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipValueDedupeKey(fieldKey: string, value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
if (fieldKey === "emails") return `emails:${v.toLowerCase()}`
|
||||||
|
if (fieldKey === "phones") {
|
||||||
|
const digits = v.replace(/\D/g, "")
|
||||||
|
return digits.length >= 6 ? `phones:${digits}` : `phones:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
if (fieldKey === "addresses") return `addresses:${v.toLowerCase()}`
|
||||||
|
return `${fieldKey}:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupChipFields(items: ChipFieldItem[], denseGrid = false) {
|
||||||
|
const map = new Map<string, ChipFieldItem[]>()
|
||||||
|
const seenValues = new Map<string, Set<string>>()
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.removed) continue
|
||||||
|
const perField = seenValues.get(item.fieldKey) ?? new Set<string>()
|
||||||
|
const dedupeKey = chipValueDedupeKey(item.fieldKey, item.value)
|
||||||
|
if (perField.has(dedupeKey)) continue
|
||||||
|
perField.add(dedupeKey)
|
||||||
|
seenValues.set(item.fieldKey, perField)
|
||||||
|
const list = map.get(item.fieldKey) ?? []
|
||||||
|
list.push(item)
|
||||||
|
map.set(item.fieldKey, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toGroup = (fieldKey: string) => ({
|
||||||
|
fieldKey,
|
||||||
|
label: FIELD_LABELS[fieldKey] ?? fieldKey,
|
||||||
|
items: map.get(fieldKey) ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const keys = sortFieldKeys([...map.keys()])
|
||||||
|
if (!denseGrid) {
|
||||||
|
return keys.map(toGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sparse: ReturnType<typeof toGroup>[] = []
|
||||||
|
const dense: ReturnType<typeof toGroup>[] = []
|
||||||
|
for (const fieldKey of keys) {
|
||||||
|
const group = toGroup(fieldKey)
|
||||||
|
if (group.items.length > 2) {
|
||||||
|
dense.push(group)
|
||||||
|
} else {
|
||||||
|
sparse.push(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...sparse, ...dense]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryFieldChipsProps {
|
||||||
|
items: ChipFieldItem[]
|
||||||
|
/** Compact grid: 1 col < md, 2 cols md–lg, 3 cols xl+; dense fields span full row */
|
||||||
|
denseGrid?: boolean
|
||||||
|
/** Pencil edit + remove */
|
||||||
|
editable?: boolean
|
||||||
|
/** X only (e.g. reject suggestion) */
|
||||||
|
dismissible?: boolean
|
||||||
|
onRemove?: (id: string) => void
|
||||||
|
onValueChange?: (id: string, value: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoveryFieldChips({
|
||||||
|
items,
|
||||||
|
denseGrid = false,
|
||||||
|
editable = false,
|
||||||
|
dismissible = false,
|
||||||
|
onRemove,
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
}: DiscoveryFieldChipsProps) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState("")
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupChipFields(items, denseGrid), [items, denseGrid])
|
||||||
|
if (groups.length === 0) return null
|
||||||
|
|
||||||
|
function startEdit(item: ChipFieldItem) {
|
||||||
|
setEditingId(item.id)
|
||||||
|
setEditValue(item.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitEdit(id: string) {
|
||||||
|
const v = editValue.trim()
|
||||||
|
if (!v) {
|
||||||
|
onRemove?.(id)
|
||||||
|
} else {
|
||||||
|
onValueChange?.(id, v)
|
||||||
|
}
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-3",
|
||||||
|
denseGrid
|
||||||
|
? "grid grid-cols-1 gap-x-3 gap-y-2 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
: "space-y-2.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.fieldKey}
|
||||||
|
className={cn(
|
||||||
|
denseGrid && CONTACTS_DISCOVERY_GRID_CELL_CLASS,
|
||||||
|
denseGrid && group.items.length > 2 && "md:col-span-2 xl:col-span-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className={cn("mb-0.5 text-[11px] font-medium", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const accessibleLabel = chipAccessibleLabel(group.label, item.value)
|
||||||
|
return editingId === item.id ? (
|
||||||
|
<Input
|
||||||
|
key={item.id}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commitEdit(item.id)
|
||||||
|
if (e.key === "Escape") setEditingId(null)
|
||||||
|
}}
|
||||||
|
onBlur={() => commitEdit(item.id)}
|
||||||
|
className="h-7 min-w-40 max-w-full flex-1 rounded-full px-3 text-xs"
|
||||||
|
aria-label={`Modifier ${accessibleLabel}`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className={CONTACTS_DISCOVERY_CHIP_CLASS}
|
||||||
|
title={accessibleLabel}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate" aria-label={accessibleLabel}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label={`Modifier ${accessibleLabel}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(editable || dismissible) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove?.(item.id)}
|
||||||
|
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label={`Retirer ${accessibleLabel}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
components/gmail/contacts-page/ignored-contacts-view.tsx
Normal file
143
components/gmail/contacts-page/ignored-contacts-view.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { flushSync } from "react-dom"
|
||||||
|
import {
|
||||||
|
useAddDiscoveredContact,
|
||||||
|
useIgnoredDiscoveredContacts,
|
||||||
|
useRejectDiscoveredProfile,
|
||||||
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
|
||||||
|
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import {
|
||||||
|
CONTACTS_HEADING_TEXT,
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import {
|
||||||
|
DiscoveryCardsMasonry,
|
||||||
|
DiscoveryCardsMasonryItem,
|
||||||
|
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
|
||||||
|
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface IgnoredContactsViewProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileToGroup(profile: ApiDiscoveredProfile): ApiDiscoveredProfileGroup {
|
||||||
|
return {
|
||||||
|
group_key: profile.id,
|
||||||
|
profile_ids: [profile.id],
|
||||||
|
display_name: profileDisplayName(profile),
|
||||||
|
primary_email: profile.primary_email,
|
||||||
|
message_count: profile.message_count,
|
||||||
|
profile,
|
||||||
|
profiles: [profile],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IgnoredContactsView({ searchQuery }: IgnoredContactsViewProps) {
|
||||||
|
const { bookId } = useContactsList()
|
||||||
|
const { data: profiles = [], isLoading } = useIgnoredDiscoveredContacts()
|
||||||
|
const addDiscoveredContact = useAddDiscoveredContact()
|
||||||
|
const rejectProfile = useRejectDiscoveredProfile()
|
||||||
|
const [removedProfileIds, setRemovedProfileIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const markProfileRemoved = useCallback((profileId: string) => {
|
||||||
|
setRemovedProfileIds((prev) => {
|
||||||
|
if (prev.has(profileId)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(profileId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const restoreProfile = useCallback((profileId: string) => {
|
||||||
|
setRemovedProfileIds((prev) => {
|
||||||
|
if (!prev.has(profileId)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(profileId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const visible = profiles.filter((p) => !removedProfileIds.has(p.id))
|
||||||
|
const q = searchQuery.trim().toLowerCase()
|
||||||
|
if (!q) return visible
|
||||||
|
return visible.filter((p) => {
|
||||||
|
const name = profileDisplayName(p).toLowerCase()
|
||||||
|
return name.includes(q) || p.primary_email.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}, [profiles, searchQuery, removedProfileIds])
|
||||||
|
|
||||||
|
function handleAdd(profile: ApiDiscoveredProfile, buildContact: () => FullContact) {
|
||||||
|
if (!bookId) return
|
||||||
|
flushSync(() => markProfileRemoved(profile.id))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
addDiscoveredContact.mutate(
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
profileId: profile.id,
|
||||||
|
contact: fullContactToApiContact(buildContact()),
|
||||||
|
},
|
||||||
|
{ onError: () => restoreProfile(profile.id) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(profileId: string) {
|
||||||
|
flushSync(() => markProfileRemoved(profileId))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rejectProfile.mutate(profileId, { onError: () => restoreProfile(profileId) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addingProfileId =
|
||||||
|
addDiscoveredContact.isPending && typeof addDiscoveredContact.variables?.profileId === "string"
|
||||||
|
? addDiscoveredContact.variables.profileId
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-6 text-foreground">
|
||||||
|
<h2 className={cn("mb-2 text-base font-medium", CONTACTS_HEADING_TEXT)}>Ignorés</h2>
|
||||||
|
<p className={cn("mb-6 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Ces expéditeurs ne sont pas dans vos contacts et ne seront plus suggérés. Vous pouvez les
|
||||||
|
ajouter à votre carnet ou les supprimer définitivement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className={cn("mb-4", CONTACTS_PAGE_SECTION_TITLE_CLASS)}>
|
||||||
|
{filtered.length} contact{filtered.length !== 1 ? "s" : ""}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Aucun contact ignoré.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DiscoveryCardsMasonry>
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<DiscoveryCardsMasonryItem key={p.id}>
|
||||||
|
<SuggestedContactCard
|
||||||
|
group={profileToGroup(p)}
|
||||||
|
mode="ignored"
|
||||||
|
addBusy={addingProfileId === p.id}
|
||||||
|
busy={rejectProfile.isPending && rejectProfile.variables === p.id}
|
||||||
|
onAdd={(buildContact) => handleAdd(p, buildContact)}
|
||||||
|
onRemove={() => handleRemove(p.id)}
|
||||||
|
/>
|
||||||
|
</DiscoveryCardsMasonryItem>
|
||||||
|
))}
|
||||||
|
</DiscoveryCardsMasonry>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,14 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useMergeDuplicates } from "@/lib/api/hooks/use-contact-mutations"
|
import { useMergeContactPair } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
|
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 { fullContactDisplayName, type MergeSuggestion } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import { AddCoordinatesView } from "./add-coordinates-view"
|
import { AddCoordinatesView } from "./add-coordinates-view"
|
||||||
|
import {
|
||||||
|
DiscoveryCardsMasonry,
|
||||||
|
DiscoveryCardsMasonryItem,
|
||||||
|
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
|
||||||
import {
|
import {
|
||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
@ -36,29 +42,80 @@ export function MergeDuplicatesView() {
|
|||||||
const { contacts, bookId } = useContactsList()
|
const { contacts, bookId } = useContactsList()
|
||||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||||
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
||||||
const mergeDuplicatesMutation = useMergeDuplicates()
|
const mergeContactPairMutation = useMergeContactPair()
|
||||||
|
|
||||||
const mergeSuggestions = useMemo(
|
const mergeSuggestions = useMemo(
|
||||||
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
|
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
|
||||||
[contacts, ignoredMergePairs]
|
[contacts, ignoredMergePairs]
|
||||||
)
|
)
|
||||||
|
const { suggestions: coordinateSuggestions } = useVisibleEnrichmentSuggestions()
|
||||||
|
|
||||||
const [mergingAll, setMergingAll] = useState(false)
|
const [mergingAll, setMergingAll] = useState(false)
|
||||||
|
const [mergingPairKey, setMergingPairKey] = useState<string | null>(null)
|
||||||
|
|
||||||
function handleMerge(_suggestion: MergeSuggestion) {
|
function handleMerge(suggestion: MergeSuggestion) {
|
||||||
mergeDuplicatesMutation.mutate({ bookId })
|
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) {
|
function handleIgnore(suggestion: MergeSuggestion) {
|
||||||
ignoreMergePair(suggestion.contactA.id, suggestion.contactB.id)
|
ignoreMergePair(suggestion.contactA.id, suggestion.contactB.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMergeAll() {
|
async function handleMergeAll() {
|
||||||
|
if (!bookId) {
|
||||||
|
toast.error("Carnet de contacts introuvable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mergeSuggestions.length === 0) return
|
||||||
|
|
||||||
setMergingAll(true)
|
setMergingAll(true)
|
||||||
mergeDuplicatesMutation.mutate(
|
try {
|
||||||
{ bookId },
|
for (const suggestion of mergeSuggestions) {
|
||||||
{ onSettled: () => setMergingAll(false) },
|
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 (
|
return (
|
||||||
@ -94,6 +151,9 @@ export function MergeDuplicatesView() {
|
|||||||
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
|
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
|
||||||
>
|
>
|
||||||
Ajouter des coordonnées
|
Ajouter des coordonnées
|
||||||
|
{coordinateSuggestions.length > 0 && (
|
||||||
|
<span className="ml-2 text-xs">({coordinateSuggestions.length})</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -120,16 +180,23 @@ export function MergeDuplicatesView() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<DiscoveryCardsMasonry>
|
||||||
{mergeSuggestions.map((suggestion) => (
|
{mergeSuggestions.map((suggestion) => {
|
||||||
<MergeSuggestionCard
|
const pairKey = mergePairKey(suggestion.contactA.id, suggestion.contactB.id)
|
||||||
key={`${suggestion.contactA.id}:${suggestion.contactB.id}`}
|
const isMerging = mergingPairKey === pairKey
|
||||||
suggestion={suggestion}
|
|
||||||
onMerge={() => handleMerge(suggestion)}
|
return (
|
||||||
onIgnore={() => handleIgnore(suggestion)}
|
<DiscoveryCardsMasonryItem key={pairKey}>
|
||||||
/>
|
<MergeSuggestionCard
|
||||||
))}
|
suggestion={suggestion}
|
||||||
</div>
|
merging={isMerging}
|
||||||
|
onMerge={() => handleMerge(suggestion)}
|
||||||
|
onIgnore={() => handleIgnore(suggestion)}
|
||||||
|
/>
|
||||||
|
</DiscoveryCardsMasonryItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DiscoveryCardsMasonry>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -140,34 +207,41 @@ export function MergeDuplicatesView() {
|
|||||||
|
|
||||||
function MergeSuggestionCard({
|
function MergeSuggestionCard({
|
||||||
suggestion,
|
suggestion,
|
||||||
|
merging,
|
||||||
onMerge,
|
onMerge,
|
||||||
onIgnore,
|
onIgnore,
|
||||||
}: {
|
}: {
|
||||||
suggestion: MergeSuggestion
|
suggestion: MergeSuggestion
|
||||||
|
merging: boolean
|
||||||
onMerge: () => void
|
onMerge: () => void
|
||||||
onIgnore: () => void
|
onIgnore: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contactA, contactB, reason } = suggestion
|
const { contactA, contactB, reason } = suggestion
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={CONTACTS_PAGE_CARD_CLASS}>
|
<div className={cn(CONTACTS_PAGE_CARD_CLASS, "overflow-hidden")}>
|
||||||
<p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
|
<p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
|
||||||
{REASON_LABELS[reason]}
|
{REASON_LABELS[reason]}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-start gap-6">
|
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
<ContactMiniCard contact={contactA} />
|
<ContactMiniCard contact={contactA} />
|
||||||
<ContactMiniCard contact={contactB} />
|
<ContactMiniCard contact={contactB} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center justify-end gap-3">
|
<div className="mt-4 flex flex-wrap items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onIgnore}
|
onClick={onIgnore}
|
||||||
|
disabled={merging}
|
||||||
className={CONTACTS_PAGE_LINK_BTN_CLASS}
|
className={CONTACTS_PAGE_LINK_BTN_CLASS}
|
||||||
>
|
>
|
||||||
Ignorer
|
Ignorer
|
||||||
</button>
|
</button>
|
||||||
<Button onClick={onMerge} className={CONTACTS_PRIMARY_BTN_CLASS}>
|
<Button
|
||||||
Fusionner
|
onClick={onMerge}
|
||||||
|
disabled={merging}
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
>
|
||||||
|
{merging ? "Fusion…" : "Fusionner"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,28 +251,30 @@ function MergeSuggestionCard({
|
|||||||
function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").FullContact }) {
|
function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").FullContact }) {
|
||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || "?"
|
||||||
const color = avatarColor(name)
|
|
||||||
const initial = senderInitial(name)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3 overflow-hidden">
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="sm" />
|
||||||
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
) : (
|
<p
|
||||||
<div
|
className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
title={name}
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
>
|
||||||
{initial}
|
{name}
|
||||||
</div>
|
</p>
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
|
|
||||||
{contact.emails[0] && (
|
{contact.emails[0] && (
|
||||||
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
|
<p
|
||||||
|
className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}
|
||||||
|
title={contact.emails[0].value}
|
||||||
|
>
|
||||||
|
{contact.emails[0].value}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{contact.phones[0] && (
|
{contact.phones[0] && (
|
||||||
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
|
<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})
|
{contact.phones[0].value} ({contact.phones[0].label})
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
315
components/gmail/contacts-page/other-contacts-view.tsx
Normal file
315
components/gmail/contacts-page/other-contacts-view.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useCallback, useState, useDeferredValue } from "react"
|
||||||
|
import { Loader2, RefreshCw, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import {
|
||||||
|
useActiveDiscoveryScan,
|
||||||
|
useAddDiscoveredContact,
|
||||||
|
useBlockDiscoveredProfile,
|
||||||
|
useCancelDiscoveryScan,
|
||||||
|
useDiscoveryCounts,
|
||||||
|
useEnrichDiscoveredProfile,
|
||||||
|
useIgnoreDiscoveredProfile,
|
||||||
|
extractDiscoveryGroupEmails,
|
||||||
|
flattenOtherContactPages,
|
||||||
|
useOtherDiscoveredContacts,
|
||||||
|
useStartDiscoveryScan,
|
||||||
|
scanProgressLabel,
|
||||||
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import {
|
||||||
|
isSuggestableDiscoveryGroup,
|
||||||
|
} from "@/lib/contacts/discovery-utils"
|
||||||
|
import {
|
||||||
|
discoveryGroupReactKey,
|
||||||
|
normalizeDiscoveryGroup,
|
||||||
|
} from "@/lib/contacts/discovery-grouping"
|
||||||
|
import type { ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
|
||||||
|
import {
|
||||||
|
CONTACTS_HEADING_TEXT,
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PAGE_INFO_BANNER_CLASS,
|
||||||
|
CONTACTS_PAGE_INFO_BANNER_ICON_CLASS,
|
||||||
|
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import {
|
||||||
|
DiscoveryCardsMasonry,
|
||||||
|
DiscoveryCardsMasonryItem,
|
||||||
|
DiscoveryCardsMasonrySentinel,
|
||||||
|
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
|
||||||
|
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
|
||||||
|
import { useDiscoveryScrollLoad } from "@/components/gmail/contacts-page/use-discovery-scroll-load"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface OtherContactsViewProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtherContactsView({ searchQuery }: OtherContactsViewProps) {
|
||||||
|
const deferredSearchQuery = useDeferredValue(searchQuery.trim())
|
||||||
|
const { bookId } = useContactsList()
|
||||||
|
const { data: discoveryCounts } = useDiscoveryCounts()
|
||||||
|
const { data: activeScan } = useActiveDiscoveryScan()
|
||||||
|
const isRunning =
|
||||||
|
activeScan != null &&
|
||||||
|
(activeScan.status === "running" || activeScan.status === "pending")
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useOtherDiscoveredContacts(!isRunning, deferredSearchQuery)
|
||||||
|
const groups = useMemo(() => flattenOtherContactPages(data), [data])
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||||
|
const startScan = useStartDiscoveryScan()
|
||||||
|
const cancelScan = useCancelDiscoveryScan()
|
||||||
|
const addDiscoveredContact = useAddDiscoveredContact()
|
||||||
|
const ignoreProfile = useIgnoreDiscoveredProfile()
|
||||||
|
const blockProfile = useBlockDiscoveredProfile()
|
||||||
|
const enrichProfile = useEnrichDiscoveredProfile()
|
||||||
|
const blockSenders = useBlockedSendersStore((s) => s.blockSenders)
|
||||||
|
const completedHandled = useRef(false)
|
||||||
|
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const dismissCard = useCallback((cardKey: string) => {
|
||||||
|
setDismissedKeys((prev) => {
|
||||||
|
if (prev.has(cardKey)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(cardKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const restoreCard = useCallback((cardKey: string) => {
|
||||||
|
setDismissedKeys((prev) => {
|
||||||
|
if (!prev.has(cardKey)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(cardKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (completedHandled.current && !isRunning) return
|
||||||
|
if (!isRunning && activeScan == null) {
|
||||||
|
completedHandled.current = true
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
}, [isRunning, activeScan, refetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRunning) completedHandled.current = false
|
||||||
|
}, [isRunning])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return groups
|
||||||
|
.map((g) => normalizeDiscoveryGroup(g))
|
||||||
|
.filter((g): g is ApiDiscoveredProfileGroup => g != null)
|
||||||
|
.filter(isSuggestableDiscoveryGroup)
|
||||||
|
.filter((g) => !dismissedKeys.has(discoveryGroupReactKey(g)))
|
||||||
|
}, [groups, dismissedKeys])
|
||||||
|
|
||||||
|
const isSearchPending = searchQuery.trim() !== deferredSearchQuery
|
||||||
|
const serverSuggestionCount =
|
||||||
|
deferredSearchQuery
|
||||||
|
? (data?.pages[0]?.total ?? filtered.length)
|
||||||
|
: (discoveryCounts?.other_contacts ?? data?.pages[0]?.total ?? filtered.length)
|
||||||
|
|
||||||
|
const suggestionCountLabel = deferredSearchQuery
|
||||||
|
? String(filtered.length)
|
||||||
|
: hasNextPage
|
||||||
|
? `${filtered.length} / ${serverSuggestionCount}`
|
||||||
|
: String(filtered.length)
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!hasNextPage || isFetchingNextPage) return
|
||||||
|
void fetchNextPage()
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||||
|
|
||||||
|
useDiscoveryScrollLoad({
|
||||||
|
sentinelRef: loadMoreRef,
|
||||||
|
hasNextPage: Boolean(hasNextPage),
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore: loadMore,
|
||||||
|
maxAutoLoads: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleScan() {
|
||||||
|
if (isRunning) return
|
||||||
|
startScan.mutate(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd(group: ApiDiscoveredProfileGroup, buildContact: () => FullContact) {
|
||||||
|
const profileId = group.profile?.id ?? group.profile_ids[0]
|
||||||
|
if (!profileId || !bookId) return
|
||||||
|
const cardKey = discoveryGroupReactKey(group)
|
||||||
|
dismissCard(cardKey)
|
||||||
|
addDiscoveredContact.mutate(
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
profileId,
|
||||||
|
contact: fullContactToApiContact(buildContact()),
|
||||||
|
},
|
||||||
|
{ onError: () => restoreCard(cardKey) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIgnore(group: ApiDiscoveredProfileGroup, profileId: string) {
|
||||||
|
const cardKey = discoveryGroupReactKey(group)
|
||||||
|
dismissCard(cardKey)
|
||||||
|
ignoreProfile.mutate(profileId, { onError: () => restoreCard(cardKey) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlock(group: ApiDiscoveredProfileGroup, profileId: string) {
|
||||||
|
const cardKey = discoveryGroupReactKey(group)
|
||||||
|
const emails = extractDiscoveryGroupEmails(group)
|
||||||
|
if (emails.length) blockSenders(emails)
|
||||||
|
dismissCard(cardKey)
|
||||||
|
blockProfile.mutate(profileId, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.emails?.length) blockSenders(res.emails)
|
||||||
|
},
|
||||||
|
onError: () => restoreCard(cardKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichingProfileId =
|
||||||
|
enrichProfile.isPending && typeof enrichProfile.variables === "string"
|
||||||
|
? enrichProfile.variables
|
||||||
|
: null
|
||||||
|
|
||||||
|
const progress = activeScan?.progress_percent ?? 0
|
||||||
|
const scanLabel = activeScan ? scanProgressLabel(activeScan) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-6 text-foreground">
|
||||||
|
<div className={cn(CONTACTS_PAGE_INFO_BANNER_CLASS, "flex flex-col gap-4")}>
|
||||||
|
<div className="flex w-full items-center gap-4">
|
||||||
|
<div className={CONTACTS_PAGE_INFO_BANNER_ICON_CLASS}>
|
||||||
|
<span className="text-2xl">📬</span>
|
||||||
|
</div>
|
||||||
|
<h2 className={cn("min-w-0 flex-1 text-base font-medium", CONTACTS_HEADING_TEXT)}>
|
||||||
|
Contacts détectés dans vos e-mails
|
||||||
|
</h2>
|
||||||
|
<div className="flex shrink-0 flex-col items-stretch gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleScan}
|
||||||
|
disabled={isRunning || startScan.isPending}
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
>
|
||||||
|
{isRunning || startScan.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isRunning ? (
|
||||||
|
"Analyse en cours…"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="sm:hidden">Analyser</span>
|
||||||
|
<span className="hidden sm:inline">Analyser toutes les boîtes</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => cancelScan.mutate()}
|
||||||
|
disabled={cancelScan.isPending}
|
||||||
|
>
|
||||||
|
{cancelScan.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={cn("w-full text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Analyse complète de toutes vos boîtes mail. Les listes de diffusion, e-mails jetables et expéditeurs spam sont exclus.
|
||||||
|
</p>
|
||||||
|
{activeScan?.status === "failed" && activeScan.error_message && (
|
||||||
|
<p className="w-full text-xs text-destructive">{activeScan.error_message}</p>
|
||||||
|
)}
|
||||||
|
{isRunning && scanLabel && activeScan && (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{scanLabel}</p>
|
||||||
|
<Progress value={Math.min(100, progress)} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
|
||||||
|
Suggestions ({suggestionCountLabel})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && !isRunning && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{isSearchPending ? "Recherche…" : "Chargement…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && filtered.length === 0 && !isRunning && (
|
||||||
|
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{deferredSearchQuery
|
||||||
|
? "Aucun contact ne correspond à votre recherche."
|
||||||
|
: "Aucun contact détecté. Lancez une analyse pour scanner tous vos messages."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DiscoveryCardsMasonry
|
||||||
|
footer={
|
||||||
|
(hasNextPage || isFetchingNextPage) ? (
|
||||||
|
<DiscoveryCardsMasonrySentinel sentinelRef={loadMoreRef}>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
|
||||||
|
{deferredSearchQuery
|
||||||
|
? `${filtered.length} sur ${serverSuggestionCount} résultats`
|
||||||
|
: `${filtered.length} sur ${serverSuggestionCount} affichés`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DiscoveryCardsMasonrySentinel>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filtered.map((group) => {
|
||||||
|
const profileId = group.profile?.id ?? group.profile_ids[0]
|
||||||
|
if (!profileId) return null
|
||||||
|
const reactKey = discoveryGroupReactKey(group)
|
||||||
|
return (
|
||||||
|
<DiscoveryCardsMasonryItem key={reactKey}>
|
||||||
|
<SuggestedContactCard
|
||||||
|
group={group}
|
||||||
|
mode="suggest"
|
||||||
|
busy={profileId === enrichingProfileId}
|
||||||
|
onAdd={(buildContact) => handleAdd(group, buildContact)}
|
||||||
|
onEnrich={() => enrichProfile.mutate(profileId)}
|
||||||
|
onIgnore={() => handleIgnore(group, profileId)}
|
||||||
|
onBlock={() => handleBlock(group, profileId)}
|
||||||
|
/>
|
||||||
|
</DiscoveryCardsMasonryItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DiscoveryCardsMasonry>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
components/gmail/contacts-page/suggested-contact-card.tsx
Normal file
293
components/gmail/contacts-page/suggested-contact-card.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -11,7 +11,7 @@ import {
|
|||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import {
|
import {
|
||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
@ -76,8 +76,6 @@ export function TrashView() {
|
|||||||
const { contact, deletedAt, reason } = entry
|
const { contact, deletedAt, reason } = entry
|
||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || "?"
|
||||||
const color = avatarColor(name)
|
|
||||||
const initial = senderInitial(name)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -88,16 +86,7 @@ export function TrashView() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="xs" />
|
||||||
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={cn("truncate", CONTACTS_HEADING_TEXT)}>{name}</span>
|
<span className={cn("truncate", CONTACTS_HEADING_TEXT)}>{name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("truncate", CONTACTS_MUTED_TEXT)}>{reason}</span>
|
<span className={cn("truncate", CONTACTS_MUTED_TEXT)}>{reason}</span>
|
||||||
|
|||||||
262
components/gmail/contacts-page/use-contact-bulk-actions.ts
Normal file
262
components/gmail/contacts-page/use-contact-bulk-actions.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
|
import { buildContactUpdatePayload } from "@/lib/api/adapters"
|
||||||
|
import {
|
||||||
|
findApiContactInCaches,
|
||||||
|
replaceContactInBookCaches,
|
||||||
|
resolveContactForUpdate,
|
||||||
|
} from "@/lib/api/contact-book-cache"
|
||||||
|
import { fetchContactByPath } from "@/lib/api/hooks/use-contact-queries"
|
||||||
|
import { contactApiPath } from "@/lib/contacts/contact-api-path"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import type { ContactBulkEditField } from "@/lib/contacts/bulk-edit-fields"
|
||||||
|
import { applyBulkFieldValue } from "@/lib/contacts/bulk-edit-fields"
|
||||||
|
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
|
||||||
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
|
import type { ContactLabelPresence } from "./contact-label-picker-block"
|
||||||
|
|
||||||
|
function contactHasLabel(contact: FullContact, labelId: string): boolean {
|
||||||
|
return contact.labels?.includes(labelId) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeLabelOnContact(
|
||||||
|
contact: FullContact,
|
||||||
|
labelId: string,
|
||||||
|
add: boolean,
|
||||||
|
): FullContact {
|
||||||
|
const current = contact.labels ?? []
|
||||||
|
const next = add
|
||||||
|
? current.includes(labelId)
|
||||||
|
? current
|
||||||
|
: [...current, labelId]
|
||||||
|
: current.filter((id) => id !== labelId)
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
labels: next.length ? next : undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContactBulkActions() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const updateContact = useUpdateContact()
|
||||||
|
const blockSenders = useBlockedSendersStore((s) => s.blockSenders)
|
||||||
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
|
||||||
|
|
||||||
|
const resolveContactEtag = useCallback(
|
||||||
|
async (contact: FullContact): Promise<FullContact | null> => {
|
||||||
|
let resolved = resolveContactForUpdate(queryClient, contact)
|
||||||
|
if (resolved.etag) return resolved
|
||||||
|
|
||||||
|
const apiPath = contactApiPath(resolved)
|
||||||
|
try {
|
||||||
|
const fetched = await fetchContactByPath(apiPath)
|
||||||
|
replaceContactInBookCaches(queryClient, apiPath, fetched)
|
||||||
|
resolved = resolveContactForUpdate(queryClient, {
|
||||||
|
...resolved,
|
||||||
|
etag: fetched.etag,
|
||||||
|
path: fetched.path ?? resolved.path,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved.etag ? resolved : null
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const persistContact = useCallback(
|
||||||
|
async (
|
||||||
|
contact: FullContact,
|
||||||
|
opts?: {
|
||||||
|
bulkField?: ContactBulkEditField
|
||||||
|
bulkValue?: string
|
||||||
|
patchLabels?: boolean
|
||||||
|
skipInvalidation?: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const resolved = await resolveContactEtag(contact)
|
||||||
|
if (!resolved?.etag) return false
|
||||||
|
|
||||||
|
const existing = findApiContactInCaches(queryClient, resolved)
|
||||||
|
const payload = buildContactUpdatePayload(existing, resolved, {
|
||||||
|
bulkField: opts?.bulkField,
|
||||||
|
bulkValue: opts?.bulkValue,
|
||||||
|
patchLabels: opts?.patchLabels,
|
||||||
|
})
|
||||||
|
await updateContact.mutateAsync({
|
||||||
|
path: contactApiPath(resolved),
|
||||||
|
etag: resolved.etag,
|
||||||
|
contact: payload,
|
||||||
|
skipInvalidation: opts?.skipInvalidation,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[queryClient, resolveContactEtag, updateContact],
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLabelPresence = useCallback(
|
||||||
|
(contacts: FullContact[], labelId: string): ContactLabelPresence => {
|
||||||
|
if (contacts.length === 0) return "none"
|
||||||
|
let n = 0
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (contactHasLabel(contact, labelId)) n++
|
||||||
|
}
|
||||||
|
if (n === 0) return "none"
|
||||||
|
if (n === contacts.length) return "all"
|
||||||
|
return "some"
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const runSequentialUpdates = useCallback(
|
||||||
|
async (
|
||||||
|
contacts: FullContact[],
|
||||||
|
buildNext: (contact: FullContact) => FullContact,
|
||||||
|
opts?: {
|
||||||
|
bulkField?: ContactBulkEditField
|
||||||
|
bulkValue?: string
|
||||||
|
patchLabels?: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let skipped = 0
|
||||||
|
let failed = 0
|
||||||
|
let updated = 0
|
||||||
|
|
||||||
|
for (const contact of contacts) {
|
||||||
|
const next = buildNext(resolveContactForUpdate(queryClient, contact))
|
||||||
|
try {
|
||||||
|
const ok = await persistContact(next, {
|
||||||
|
...opts,
|
||||||
|
skipInvalidation: true,
|
||||||
|
})
|
||||||
|
if (ok) updated++
|
||||||
|
else skipped++
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["contacts"] })
|
||||||
|
|
||||||
|
return { skipped, failed, updated }
|
||||||
|
},
|
||||||
|
[persistContact, queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleLabelOnContacts = useCallback(
|
||||||
|
async (contacts: FullContact[], labelId: string) => {
|
||||||
|
if (contacts.length === 0) return
|
||||||
|
const presence = getLabelPresence(contacts, labelId)
|
||||||
|
const shouldAdd = presence !== "all"
|
||||||
|
|
||||||
|
const targets = contacts.filter((contact) => {
|
||||||
|
const has = contactHasLabel(contact, labelId)
|
||||||
|
return shouldAdd ? !has : has
|
||||||
|
})
|
||||||
|
|
||||||
|
const { skipped, failed, updated } = await runSequentialUpdates(
|
||||||
|
targets,
|
||||||
|
(contact) => mergeLabelOnContact(contact, labelId, shouldAdd),
|
||||||
|
{ patchLabels: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updated === 0 && skipped === 0 && failed === 0) return
|
||||||
|
if (failed > 0) {
|
||||||
|
toast.error(
|
||||||
|
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (skipped > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getLabelPresence, runSequentialUpdates],
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAndApplyLabel = useCallback(
|
||||||
|
async (contacts: FullContact[], labelText: string) => {
|
||||||
|
addLabelRowFromSidebar(labelText)
|
||||||
|
const row = useNavStore
|
||||||
|
.getState()
|
||||||
|
.labelRows.find(
|
||||||
|
(r) => r.label.toLowerCase() === labelText.trim().toLowerCase(),
|
||||||
|
)
|
||||||
|
if (row) await toggleLabelOnContacts(contacts, row.id)
|
||||||
|
},
|
||||||
|
[addLabelRowFromSidebar, toggleLabelOnContacts],
|
||||||
|
)
|
||||||
|
|
||||||
|
const blockContacts = useCallback(
|
||||||
|
(contacts: FullContact[]) => {
|
||||||
|
const emails = contacts
|
||||||
|
.flatMap((c) => c.emails.map((e) => e.value))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (emails.length === 0) {
|
||||||
|
toast.error("Aucune adresse e-mail à bloquer dans la sélection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blockSenders(emails)
|
||||||
|
toast.success(
|
||||||
|
`${emails.length} adresse${emails.length > 1 ? "s" : ""} bloquée${emails.length > 1 ? "s" : ""}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[blockSenders],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyBulkField = useCallback(
|
||||||
|
async (contacts: FullContact[], field: ContactBulkEditField, value: string) => {
|
||||||
|
const toUpdate = contacts.map((c) => applyBulkFieldValue(c, field, value))
|
||||||
|
const { skipped, failed, updated } = await runSequentialUpdates(
|
||||||
|
toUpdate,
|
||||||
|
(contact) => contact,
|
||||||
|
{ bulkField: field, bulkValue: value },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
toast.success(
|
||||||
|
`${updated} contact${updated > 1 ? "s" : ""} mis à jour`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
toast.error(
|
||||||
|
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (skipped > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runSequentialUpdates],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveLabelVisualById = useCallback(
|
||||||
|
(labelId: string) => {
|
||||||
|
const row = labelRows.find((r) => r.id === labelId)
|
||||||
|
if (row?.icon) return { kind: "iconify" as const, icon: row.icon }
|
||||||
|
if (row?.color) return { kind: "dot" as const, colorClass: row.color }
|
||||||
|
return { kind: "dot" as const, colorClass: "bg-gray-400" }
|
||||||
|
},
|
||||||
|
[labelRows],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
getLabelPresence,
|
||||||
|
toggleLabelOnContacts,
|
||||||
|
createAndApplyLabel,
|
||||||
|
blockContacts,
|
||||||
|
applyBulkField,
|
||||||
|
resolveLabelVisualById,
|
||||||
|
isUpdating: updateContact.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
79
components/gmail/contacts-page/use-discovery-scroll-load.ts
Normal file
79
components/gmail/contacts-page/use-discovery-scroll-load.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react"
|
||||||
|
|
||||||
|
interface UseDiscoveryScrollLoadOptions {
|
||||||
|
sentinelRef: RefObject<HTMLElement | null>
|
||||||
|
hasNextPage: boolean
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
onLoadMore: () => void
|
||||||
|
/** Pages chargées auto sans scroll (évite de tout prefetch si le sentinel est visible) */
|
||||||
|
maxAutoLoads?: number
|
||||||
|
scrollRootSelector?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryScrollLoad({
|
||||||
|
sentinelRef,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore,
|
||||||
|
maxAutoLoads = 1,
|
||||||
|
scrollRootSelector = "main.overflow-y-auto",
|
||||||
|
}: UseDiscoveryScrollLoadOptions) {
|
||||||
|
const autoLoadsRef = useRef(0)
|
||||||
|
const userScrolledRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
autoLoadsRef.current = 0
|
||||||
|
userScrolledRef.current = false
|
||||||
|
}
|
||||||
|
}, [hasNextPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root =
|
||||||
|
document.querySelector(scrollRootSelector) ??
|
||||||
|
sentinelRef.current?.closest("main")
|
||||||
|
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (root.scrollTop > 40) userScrolledRef.current = true
|
||||||
|
}
|
||||||
|
root.addEventListener("scroll", onScroll, { passive: true })
|
||||||
|
return () => root.removeEventListener("scroll", onScroll)
|
||||||
|
}, [scrollRootSelector, sentinelRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current
|
||||||
|
if (!el || !hasNextPage) return
|
||||||
|
|
||||||
|
const root =
|
||||||
|
document.querySelector(scrollRootSelector) ??
|
||||||
|
el.closest("main")
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry?.isIntersecting || isFetchingNextPage) return
|
||||||
|
if (!userScrolledRef.current && autoLoadsRef.current >= maxAutoLoads) return
|
||||||
|
autoLoadsRef.current += 1
|
||||||
|
onLoadMore()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: root instanceof Element ? root : null,
|
||||||
|
rootMargin: "160px",
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [
|
||||||
|
sentinelRef,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore,
|
||||||
|
scrollRootSelector,
|
||||||
|
maxAutoLoads,
|
||||||
|
])
|
||||||
|
}
|
||||||
39
components/gmail/contacts-page/use-entering-item-keys.ts
Normal file
39
components/gmail/contacts-page/use-entering-item-keys.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"
|
||||||
|
|
||||||
|
function childKey(child: ReactNode, index: number): string {
|
||||||
|
if (child && typeof child === "object" && "key" in child) {
|
||||||
|
return String((child as ReactElement).key ?? index)
|
||||||
|
}
|
||||||
|
return String(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clés des items ajoutés récemment (pour animation d'entrée unique). */
|
||||||
|
export function useEnteringItemKeys(items: ReactNode[]): Set<string> {
|
||||||
|
const seenKeysRef = useRef(new Set<string>())
|
||||||
|
const [enteringKeys, setEnteringKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const itemKeys = items.map(childKey).join("\0")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keys = items.map(childKey)
|
||||||
|
const keySet = new Set(keys)
|
||||||
|
const added = keys.filter((key) => !seenKeysRef.current.has(key))
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
seenKeysRef.current.add(key)
|
||||||
|
}
|
||||||
|
for (const key of [...seenKeysRef.current]) {
|
||||||
|
if (!keySet.has(key)) seenKeysRef.current.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.length === 0) return
|
||||||
|
|
||||||
|
setEnteringKeys(new Set(added))
|
||||||
|
const timer = window.setTimeout(() => setEnteringKeys(new Set()), 360)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [itemKeys, items])
|
||||||
|
|
||||||
|
return enteringKeys
|
||||||
|
}
|
||||||
115
components/gmail/contacts/contact-avatar-picker.tsx
Normal file
115
components/gmail/contacts/contact-avatar-picker.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
import { Plus, User, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
|
import { readAvatarFromFile } from "@/lib/contact-avatar"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS,
|
||||||
|
CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS,
|
||||||
|
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
|
||||||
|
interface ContactAvatarPickerProps {
|
||||||
|
avatarUrl?: string
|
||||||
|
displayName: string
|
||||||
|
email?: string
|
||||||
|
onChange: (avatarUrl: string | undefined) => void
|
||||||
|
variant?: "panel" | "page"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactAvatarPicker({
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
onChange,
|
||||||
|
variant = "panel",
|
||||||
|
className,
|
||||||
|
}: ContactAvatarPickerProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const isPage = variant === "page"
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ""
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAvatarFromFile(file)
|
||||||
|
onChange(dataUrl)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Impossible d'ajouter la photo.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
fileRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPhoto = !!avatarUrl || !!displayName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex flex-col items-center", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
className="group relative rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||||
|
aria-label={avatarUrl ? "Changer la photo" : "Ajouter une photo"}
|
||||||
|
>
|
||||||
|
{hasPhoto ? (
|
||||||
|
<ContactAvatar
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
name={displayName}
|
||||||
|
email={email}
|
||||||
|
size={isPage ? "2xl" : "lg"}
|
||||||
|
/>
|
||||||
|
) : isPage ? (
|
||||||
|
<div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
|
||||||
|
<User className="h-12 w-12" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avatarUrl ? (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={removePhoto}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-foreground"
|
||||||
|
aria-label="Supprimer la photo"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
components/gmail/contacts/contact-avatar.tsx
Normal file
78
components/gmail/contacts/contact-avatar.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { gravatarUrl, primaryContactEmail } from "@/lib/contact-avatar"
|
||||||
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
|
||||||
|
export type ContactAvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
|
||||||
|
|
||||||
|
const SIZE_CONFIG: Record<
|
||||||
|
ContactAvatarSize,
|
||||||
|
{ className: string; gravatar: number; text: string }
|
||||||
|
> = {
|
||||||
|
xs: { className: "size-8", gravatar: 64, text: "text-xs" },
|
||||||
|
sm: { className: "size-10", gravatar: 80, text: "text-sm" },
|
||||||
|
md: { className: "size-14", gravatar: 112, text: "text-lg" },
|
||||||
|
lg: { className: "size-20", gravatar: 160, text: "text-2xl" },
|
||||||
|
xl: { className: "size-24", gravatar: 192, text: "text-3xl" },
|
||||||
|
"2xl": { className: "size-28", gravatar: 224, text: "text-4xl" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactAvatarProps {
|
||||||
|
contact?: Pick<FullContact, "avatarUrl" | "emails" | "firstName" | "lastName">
|
||||||
|
/** Override display name for initials fallback. */
|
||||||
|
name?: string
|
||||||
|
/** Override email for Gravatar fallback. */
|
||||||
|
email?: string
|
||||||
|
/** Override stored avatar URL. */
|
||||||
|
avatarUrl?: string
|
||||||
|
size?: ContactAvatarSize
|
||||||
|
className?: string
|
||||||
|
alt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contactAvatarLabel(
|
||||||
|
contact: Pick<FullContact, "firstName" | "lastName" | "emails"> | undefined,
|
||||||
|
nameOverride?: string,
|
||||||
|
emailOverride?: string,
|
||||||
|
): string {
|
||||||
|
if (nameOverride?.trim()) return nameOverride.trim()
|
||||||
|
if (contact) {
|
||||||
|
const fromName = `${contact.firstName ?? ""} ${contact.lastName ?? ""}`.trim()
|
||||||
|
if (fromName) return fromName
|
||||||
|
}
|
||||||
|
return emailOverride?.trim() || primaryContactEmail(contact ?? {}) || "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactAvatar({
|
||||||
|
contact,
|
||||||
|
name: nameOverride,
|
||||||
|
email: emailOverride,
|
||||||
|
avatarUrl: avatarUrlOverride,
|
||||||
|
size = "sm",
|
||||||
|
className,
|
||||||
|
alt,
|
||||||
|
}: ContactAvatarProps) {
|
||||||
|
const config = SIZE_CONFIG[size]
|
||||||
|
const name = contactAvatarLabel(contact, nameOverride, emailOverride)
|
||||||
|
const email = emailOverride?.trim() || primaryContactEmail(contact ?? {})
|
||||||
|
const avatarUrl = avatarUrlOverride ?? contact?.avatarUrl
|
||||||
|
const gravatar = email ? gravatarUrl(email, config.gravatar) : undefined
|
||||||
|
const initial = senderInitial(name)
|
||||||
|
const color = avatarColor(name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className={cn("shrink-0", config.className, className)}>
|
||||||
|
{avatarUrl ? <AvatarImage src={avatarUrl} alt={alt ?? name} /> : null}
|
||||||
|
{gravatar ? <AvatarImage src={gravatar} alt={alt ?? name} /> : null}
|
||||||
|
<AvatarFallback
|
||||||
|
className={cn("font-medium text-white", config.text)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
X,
|
X,
|
||||||
Mail,
|
Mail,
|
||||||
@ -18,8 +19,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
||||||
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import { useComposeActions } from "@/lib/compose-context"
|
import { useComposeActions } from "@/lib/compose-context"
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
@ -38,6 +39,9 @@ import {
|
|||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
||||||
|
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
|
||||||
|
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
|
||||||
|
|
||||||
interface ContactDetailViewProps {
|
interface ContactDetailViewProps {
|
||||||
contactId: string | null
|
contactId: string | null
|
||||||
@ -71,6 +75,9 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
const { contacts } = useContactsList()
|
const { contacts } = useContactsList()
|
||||||
const { openComposeWithInitial } = useComposeActions()
|
const { openComposeWithInitial } = useComposeActions()
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
const { data: llmSettings } = useLLMSettings()
|
||||||
|
const [improveOpen, setImproveOpen] = useState(false)
|
||||||
|
const llmReady = isLLMConfigured(llmSettings)
|
||||||
|
|
||||||
const contact = contacts.find((c) => c.id === contactId)
|
const contact = contacts.find((c) => c.id === contactId)
|
||||||
|
|
||||||
@ -98,8 +105,6 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
|
|
||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
||||||
const color = avatarColor(name)
|
|
||||||
const initial = senderInitial(name)
|
|
||||||
const primaryEmail = contact.emails[0]?.value
|
const primaryEmail = contact.emails[0]?.value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -108,6 +113,22 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
||||||
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
||||||
|
onClick={() => setImproveOpen(true)}
|
||||||
|
disabled={!llmReady}
|
||||||
|
aria-label="Amélioration IA"
|
||||||
|
title={
|
||||||
|
llmReady
|
||||||
|
? "Améliorer la fiche avec l'IA"
|
||||||
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -142,20 +163,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
<div className="w-full min-w-0 max-w-full overflow-x-hidden">
|
<div className="w-full min-w-0 max-w-full overflow-x-hidden">
|
||||||
{/* Avatar + Name */}
|
{/* Avatar + Name */}
|
||||||
<div className="flex flex-col items-center px-4 pt-6 pb-4">
|
<div className="flex flex-col items-center px-4 pt-6 pb-4">
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="lg" />
|
||||||
<img
|
|
||||||
src={contact.avatarUrl}
|
|
||||||
alt={name}
|
|
||||||
className="h-20 w-20 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h2 className={cn("mt-3 max-w-full truncate px-2 text-center text-lg font-medium", CONTACTS_HEADING_TEXT)}>
|
<h2 className={cn("mt-3 max-w-full truncate px-2 text-center text-lg font-medium", CONTACTS_HEADING_TEXT)}>
|
||||||
{name}
|
{name}
|
||||||
</h2>
|
</h2>
|
||||||
@ -186,8 +194,25 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
|
<div className="flex min-w-0 flex-col items-center gap-2 px-4 pb-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full max-w-xs rounded-full"
|
||||||
|
onClick={() => setImproveOpen(true)}
|
||||||
|
disabled={!llmReady}
|
||||||
|
title={
|
||||||
|
llmReady
|
||||||
|
? undefined
|
||||||
|
: "Configurez un fournisseur LLM dans les réglages contacts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
|
||||||
|
Amélioration IA
|
||||||
|
</Button>
|
||||||
{primaryEmail && (
|
{primaryEmail && (
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2 px-4 pb-4">
|
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}
|
className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}
|
||||||
@ -214,6 +239,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contact details */}
|
{/* Contact details */}
|
||||||
<div className={cn("min-w-0", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
<div className={cn("min-w-0", CONTACTS_PANEL_DIVIDER_CLASS)}>
|
||||||
@ -307,6 +333,12 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
<ContactImproveDialog
|
||||||
|
contact={contact}
|
||||||
|
open={improveOpen}
|
||||||
|
onOpenChange={setImproveOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,14 +42,15 @@ import {
|
|||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { contactApiPath } from "@/lib/contacts/contact-api-path"
|
||||||
|
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
|
||||||
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
|
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
import {
|
import {
|
||||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||||
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
|
|
||||||
CONTACTS_PANEL_CARD_CLASS,
|
CONTACTS_PANEL_CARD_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
||||||
@ -119,6 +120,7 @@ const contactFormSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
notes: z.string().optional().default(""),
|
notes: z.string().optional().default(""),
|
||||||
labels: z.array(z.string()).optional().default([]),
|
labels: z.array(z.string()).optional().default([]),
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ContactFormValues = z.infer<typeof contactFormSchema>
|
type ContactFormValues = z.infer<typeof contactFormSchema>
|
||||||
@ -175,6 +177,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
birthday: { day: undefined, month: undefined, year: undefined },
|
birthday: { day: undefined, month: undefined, year: undefined },
|
||||||
notes: "",
|
notes: "",
|
||||||
labels: [],
|
labels: [],
|
||||||
|
avatarUrl: undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -217,6 +220,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
birthday: { day: undefined, month: undefined, year: undefined },
|
birthday: { day: undefined, month: undefined, year: undefined },
|
||||||
notes: "",
|
notes: "",
|
||||||
labels: [],
|
labels: [],
|
||||||
|
avatarUrl: undefined,
|
||||||
})
|
})
|
||||||
clearCreateDraft()
|
clearCreateDraft()
|
||||||
}, [mode, createDraft, reset, clearCreateDraft])
|
}, [mode, createDraft, reset, clearCreateDraft])
|
||||||
@ -266,12 +270,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
},
|
},
|
||||||
notes: contact.notes ?? "",
|
notes: contact.notes ?? "",
|
||||||
labels: contact.labels ?? [],
|
labels: contact.labels ?? [],
|
||||||
|
avatarUrl: contact.avatarUrl,
|
||||||
})
|
})
|
||||||
}, [mode, contactId, contacts, reset])
|
}, [mode, contactId, contacts, reset])
|
||||||
|
|
||||||
const firstName = watch("firstName")
|
const firstName = watch("firstName")
|
||||||
const lastName = watch("lastName")
|
const lastName = watch("lastName")
|
||||||
const watchedEmails = watch("emails")
|
const watchedEmails = watch("emails")
|
||||||
|
const avatarUrl = watch("avatarUrl")
|
||||||
const currentLabels = watch("labels") ?? []
|
const currentLabels = watch("labels") ?? []
|
||||||
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||||
|
|
||||||
@ -317,6 +323,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
notes: data.notes || undefined,
|
notes: data.notes || undefined,
|
||||||
labels: data.labels?.length ? data.labels : undefined,
|
labels: data.labels?.length ? data.labels : undefined,
|
||||||
|
avatarUrl: data.avatarUrl || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@ -338,24 +345,43 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
const id = created?.uid ?? tempId
|
const id = created?.uid ?? tempId
|
||||||
setView("view", id)
|
setView("view", id)
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else if (contactId) {
|
} else if (contactId && existingContact) {
|
||||||
const fullContact: FullContact = {
|
const fullContact: FullContact = {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
|
path: existingContact.path,
|
||||||
|
etag: existingContact.etag,
|
||||||
...payload,
|
...payload,
|
||||||
firstName: payload.firstName ?? "",
|
firstName: payload.firstName ?? "",
|
||||||
lastName: payload.lastName ?? "",
|
lastName: payload.lastName ?? "",
|
||||||
emails: payload.emails ?? [],
|
emails: payload.emails ?? [],
|
||||||
phones: payload.phones ?? [],
|
phones: payload.phones ?? [],
|
||||||
createdAt: Date.now(),
|
createdAt: existingContact.createdAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
updateContactMutation.mutate({
|
if (!existingContact.etag) {
|
||||||
path: contactId,
|
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
|
||||||
contact: fullContactToApiContact(fullContact),
|
return
|
||||||
})
|
}
|
||||||
setView("view", contactId)
|
updateContactMutation.mutate(
|
||||||
|
{
|
||||||
|
path: contactApiPath(fullContact),
|
||||||
|
etag: existingContact.etag,
|
||||||
|
contact: fullContactToApiContact(fullContact),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setView("view", contactId),
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
|
||||||
|
toast.error(msg)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,18 +431,13 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex flex-col items-center py-6">
|
<div className="flex flex-col items-center py-6">
|
||||||
{displayName ? (
|
<ContactAvatarPicker
|
||||||
<div
|
variant="panel"
|
||||||
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
|
avatarUrl={avatarUrl}
|
||||||
style={{ backgroundColor: avatarColor(displayName) }}
|
displayName={displayName}
|
||||||
>
|
email={watchedEmails?.find((e) => e.value?.trim())?.value}
|
||||||
{senderInitial(displayName)}
|
onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })}
|
||||||
</div>
|
/>
|
||||||
) : (
|
|
||||||
<div className={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
|
|
||||||
<User className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
|
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
||||||
import {
|
import {
|
||||||
CONTACTS_PANEL_ROW_CLASS,
|
CONTACTS_PANEL_ROW_CLASS,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
@ -18,8 +18,6 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
|
|||||||
const displayName = fullContactDisplayName(contact)
|
const displayName = fullContactDisplayName(contact)
|
||||||
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
||||||
const subtitle = contact.emails[0]?.value || contact.phones[0]?.value || ""
|
const subtitle = contact.emails[0]?.value || contact.phones[0]?.value || ""
|
||||||
const initial = senderInitial(name)
|
|
||||||
const bgColor = avatarColor(name)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -30,20 +28,7 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
|
|||||||
CONTACTS_PANEL_ROW_CLASS,
|
CONTACTS_PANEL_ROW_CLASS,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{contact.avatarUrl ? (
|
<ContactAvatar contact={contact} name={name} size="sm" />
|
||||||
<img
|
|
||||||
src={contact.avatarUrl}
|
|
||||||
alt={name}
|
|
||||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
|
||||||
style={{ backgroundColor: bgColor }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{name}</p>
|
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{name}</p>
|
||||||
{subtitle && displayName ? (
|
{subtitle && displayName ? (
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ContactRow } from "./contact-row"
|
import { ContactRow } from "./contact-row"
|
||||||
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
||||||
|
import { ContactsLoadState } from "./contacts-load-state"
|
||||||
|
|
||||||
export function ContactsListView() {
|
export function ContactsListView() {
|
||||||
const {
|
const {
|
||||||
@ -35,7 +36,7 @@ export function ContactsListView() {
|
|||||||
showContactsList,
|
showContactsList,
|
||||||
closePanel,
|
closePanel,
|
||||||
} = useContactsStore()
|
} = useContactsStore()
|
||||||
const { contacts } = useContactsList()
|
const { contacts, isLoading, isError, error, refetch } = useContactsList()
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@ -153,9 +154,17 @@ export function ContactsListView() {
|
|||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<CreateContactButton onClick={() => setView("create")} />
|
<CreateContactButton onClick={() => setView("create")} />
|
||||||
<div className={CONTACTS_PANEL_SECTION_LABEL_CLASS}>
|
<div className={CONTACTS_PANEL_SECTION_LABEL_CLASS}>
|
||||||
Contacts ({contacts.length})
|
Contacts ({isLoading ? "…" : contacts.length})
|
||||||
</div>
|
</div>
|
||||||
{groupedContacts.map((group) => (
|
{(isLoading || isError) && (
|
||||||
|
<ContactsLoadState
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isError && groupedContacts.map((group) => (
|
||||||
<div key={group.letter}>
|
<div key={group.letter}>
|
||||||
<div className={CONTACTS_PANEL_LETTER_CLASS}>{group.letter}</div>
|
<div className={CONTACTS_PANEL_LETTER_CLASS}>{group.letter}</div>
|
||||||
{group.items.map((contact) => (
|
{group.items.map((contact) => (
|
||||||
|
|||||||
58
components/gmail/contacts/contacts-load-state.tsx
Normal file
58
components/gmail/contacts/contacts-load-state.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ApiRequestError } from "@/lib/api/client"
|
||||||
|
import { CONTACTS_MUTED_TEXT } from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ContactsLoadStateProps {
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
if (error.code === "contacts_unavailable") {
|
||||||
|
return "Connexion au carnet d'adresses indisponible. Réessayez dans quelques secondes."
|
||||||
|
}
|
||||||
|
if (error.code === "auth.unavailable") {
|
||||||
|
return "Service d'authentification indisponible. Vérifiez que le backend est démarré."
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return "Impossible de charger les contacts."
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsLoadState({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
className,
|
||||||
|
}: ContactsLoadStateProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-center gap-2 py-12 text-sm", CONTACTS_MUTED_TEXT, className)}>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Chargement des contacts…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isError) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col items-center gap-3 px-6 py-12 text-center", className)}>
|
||||||
|
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>{errorMessage(error)}</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="rounded-full" onClick={onRetry}>
|
||||||
|
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -595,7 +595,7 @@ const mailPaginationControls = (mode: "list" | "view") => {
|
|||||||
if (variant === "reading-pane") {
|
if (variant === "reading-pane") {
|
||||||
return (
|
return (
|
||||||
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
|
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
|
||||||
{openMailToolbar(true)}
|
{openMailToolbar(false)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{mailPaginationControls("view")}
|
{mailPaginationControls("view")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,10 @@ import { findContactByEmail } from "@/lib/contacts/find-contact"
|
|||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
|
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
|
||||||
import { normalizeMailAddress } from "@/lib/mail-message-participants"
|
import { normalizeMailAddress } from "@/lib/mail-message-participants"
|
||||||
|
import {
|
||||||
|
isBlockedSenderEmail,
|
||||||
|
useBlockedSendersStore,
|
||||||
|
} from "@/lib/stores/blocked-senders-store"
|
||||||
import {
|
import {
|
||||||
isMessageRemoteContentAllowed,
|
isMessageRemoteContentAllowed,
|
||||||
isTrustedSenderEmail,
|
isTrustedSenderEmail,
|
||||||
@ -39,6 +43,7 @@ export function MessageBodyContent({
|
|||||||
const selfEmails = useSelfMailEmails()
|
const selfEmails = useSelfMailEmails()
|
||||||
const { contacts } = useContactsList()
|
const { contacts } = useContactsList()
|
||||||
const trustedSenderEmails = useTrustedSendersStore((s) => s.trustedSenderEmails)
|
const trustedSenderEmails = useTrustedSendersStore((s) => s.trustedSenderEmails)
|
||||||
|
const blockedSenderEmails = useBlockedSendersStore((s) => s.blockedSenderEmails)
|
||||||
const allowedMessageIds = useTrustedSendersStore((s) => s.allowedMessageIds)
|
const allowedMessageIds = useTrustedSendersStore((s) => s.allowedMessageIds)
|
||||||
const trustSender = useTrustedSendersStore((s) => s.trustSender)
|
const trustSender = useTrustedSendersStore((s) => s.trustSender)
|
||||||
const allowMessageRemoteContent = useTrustedSendersStore(
|
const allowMessageRemoteContent = useTrustedSendersStore(
|
||||||
@ -53,6 +58,7 @@ export function MessageBodyContent({
|
|||||||
|
|
||||||
const isContact = Boolean(findContactByEmail(contacts, senderEmail))
|
const isContact = Boolean(findContactByEmail(contacts, senderEmail))
|
||||||
const isTrusted = isTrustedSenderEmail(trustedSenderEmails, senderEmail)
|
const isTrusted = isTrustedSenderEmail(trustedSenderEmails, senderEmail)
|
||||||
|
const isBlocked = isBlockedSenderEmail(blockedSenderEmails, senderEmail)
|
||||||
const isMessageAllowed = isMessageRemoteContentAllowed(
|
const isMessageAllowed = isMessageRemoteContentAllowed(
|
||||||
allowedMessageIds,
|
allowedMessageIds,
|
||||||
messageId
|
messageId
|
||||||
@ -100,7 +106,7 @@ export function MessageBodyContent({
|
|||||||
|
|
||||||
const blockRemoteContent = isFromSelf
|
const blockRemoteContent = isFromSelf
|
||||||
? false
|
? false
|
||||||
: isSpam || (hasRemoteContent && !remoteContentAllowed)
|
: isSpam || isBlocked || (hasRemoteContent && !remoteContentAllowed)
|
||||||
|
|
||||||
const showRemoteBanner =
|
const showRemoteBanner =
|
||||||
!isFromSelf && !isSpam && hasRemoteContent && !remoteContentAllowed
|
!isFromSelf && !isSpam && hasRemoteContent && !remoteContentAllowed
|
||||||
|
|||||||
@ -86,9 +86,14 @@ function FavoriteAppTile({ app }: { app: FavoriteApp }) {
|
|||||||
|
|
||||||
interface HeaderAccountActionsProps {
|
interface HeaderAccountActionsProps {
|
||||||
className?: string
|
className?: string
|
||||||
|
/** When set, the settings button navigates here instead of opening quick settings. */
|
||||||
|
settingsHref?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
export function HeaderAccountActions({
|
||||||
|
className,
|
||||||
|
settingsHref,
|
||||||
|
}: HeaderAccountActionsProps) {
|
||||||
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
||||||
const appsMenuRef = useRef<HTMLDivElement>(null)
|
const appsMenuRef = useRef<HTMLDivElement>(null)
|
||||||
@ -136,9 +141,17 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={HEADER_ICON_BTN_CLASS}
|
className={HEADER_ICON_BTN_CLASS}
|
||||||
aria-label="Réglages"
|
aria-label="Réglages"
|
||||||
onClick={() => openQuickSettings(true)}
|
{...(settingsHref
|
||||||
|
? { asChild: true }
|
||||||
|
: { onClick: () => openQuickSettings(true) })}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
{settingsHref ? (
|
||||||
|
<Link href={settingsHref}>
|
||||||
|
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative hidden sm:block" ref={appsMenuRef}>
|
<div className="relative hidden sm:block" ref={appsMenuRef}>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
||||||
|
import { scoreApiContact } from "@/lib/contacts/contact-match-score"
|
||||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||||
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||||
import {
|
import {
|
||||||
@ -107,7 +108,7 @@ export function MailSearchBar({
|
|||||||
},
|
},
|
||||||
email: c.email ?? "",
|
email: c.email ?? "",
|
||||||
displayName: c.full_name,
|
displayName: c.full_name,
|
||||||
score: 1,
|
score: scoreApiContact(c, inputValue),
|
||||||
}))
|
}))
|
||||||
}, [inputValue, searchContactResults])
|
}, [inputValue, searchContactResults])
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
||||||
|
import { scoreApiContact } from "@/lib/contacts/contact-match-score"
|
||||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||||
import {
|
import {
|
||||||
bestCompletion,
|
bestCompletion,
|
||||||
@ -98,7 +99,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
|||||||
},
|
},
|
||||||
email: c.email ?? "",
|
email: c.email ?? "",
|
||||||
displayName: c.full_name,
|
displayName: c.full_name,
|
||||||
score: 1,
|
score: scoreApiContact(c, inputValue),
|
||||||
}))
|
}))
|
||||||
}, [inputValue, searchContactResults])
|
}, [inputValue, searchContactResults])
|
||||||
|
|
||||||
|
|||||||
106
components/gmail/settings/automation/llm-model-suggest-input.tsx
Normal file
106
components/gmail/settings/automation/llm-model-suggest-input.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useDiscoverLLMModels } from '@/lib/api/hooks/use-contact-discovery'
|
||||||
|
import { CONTACTS_MUTED_TEXT } from '@/lib/contacts-chrome-classes'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 4
|
||||||
|
|
||||||
|
interface LLMModelSuggestInputProps {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey?: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LLMModelSuggestInput({
|
||||||
|
baseUrl,
|
||||||
|
apiKey = '',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: LLMModelSuggestInputProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [debouncedBaseUrl, setDebouncedBaseUrl] = useState(baseUrl)
|
||||||
|
const [debouncedApiKey, setDebouncedApiKey] = useState(apiKey)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedBaseUrl(baseUrl)
|
||||||
|
setDebouncedApiKey(apiKey)
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [baseUrl, apiKey])
|
||||||
|
|
||||||
|
const { data, isFetching, isError } = useDiscoverLLMModels(debouncedBaseUrl, debouncedApiKey)
|
||||||
|
const models = data?.models ?? []
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = value.trim().toLowerCase()
|
||||||
|
const matches = q
|
||||||
|
? models.filter((model) => model.toLowerCase().includes(q))
|
||||||
|
: models
|
||||||
|
return matches.slice(0, MAX_SUGGESTIONS)
|
||||||
|
}, [models, value])
|
||||||
|
|
||||||
|
const showDropdown = open && !isFetching && filtered.length > 0
|
||||||
|
|
||||||
|
function pickModel(model: string) {
|
||||||
|
onChange(model)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className={cn('h-9', className)}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => setOpen(false), 150)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showDropdown ? (
|
||||||
|
<ul className="absolute z-20 mt-1 w-full rounded-md border border-border bg-popover py-1 shadow-md">
|
||||||
|
{filtered.map((model) => (
|
||||||
|
<li key={model}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-2 py-1.5 text-left text-xs hover:bg-muted"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
pickModel(model)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block truncate font-mono">{model}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{baseUrl.trim() ? (
|
||||||
|
<p className={cn('text-[11px]', CONTACTS_MUTED_TEXT)}>
|
||||||
|
{isFetching
|
||||||
|
? 'Chargement des modèles…'
|
||||||
|
: isError
|
||||||
|
? 'Impossible de récupérer les modèles pour cette URL.'
|
||||||
|
: models.length > 0
|
||||||
|
? `${models.length} modèle${models.length > 1 ? 's' : ''} disponible${models.length > 1 ? 's' : ''}.`
|
||||||
|
: 'Aucun modèle trouvé pour cette URL.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
229
components/gmail/settings/automation/llm-providers-panel.tsx
Normal file
229
components/gmail/settings/automation/llm-providers-panel.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
useLLMSettings,
|
||||||
|
useUpdateLLMSettings,
|
||||||
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
||||||
|
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
||||||
|
import {
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function emptyProvider(): ApiLLMProvider {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: "",
|
||||||
|
base_url: "https://api.openai.com/v1",
|
||||||
|
api_key: "",
|
||||||
|
default_model: "gpt-4o-mini",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LLMProvidersPanel() {
|
||||||
|
const { data: remote, isLoading } = useLLMSettings()
|
||||||
|
const updateSettings = useUpdateLLMSettings()
|
||||||
|
const [draft, setDraft] = useState<ApiLLMSettings>({
|
||||||
|
default_provider_id: "",
|
||||||
|
providers: [],
|
||||||
|
})
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remote) {
|
||||||
|
setDraft({
|
||||||
|
...remote,
|
||||||
|
providers: remote.providers ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [remote])
|
||||||
|
|
||||||
|
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const providers = [...prev.providers]
|
||||||
|
providers[index] = { ...providers[index], ...patch }
|
||||||
|
return { ...prev, providers }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProvider() {
|
||||||
|
const p = emptyProvider()
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: [...prev.providers, p],
|
||||||
|
default_provider_id: prev.default_provider_id || p.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProvider(index: number) {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const removed = prev.providers[index]
|
||||||
|
const providers = prev.providers.filter((_, i) => i !== index)
|
||||||
|
let defaultId = prev.default_provider_id
|
||||||
|
if (defaultId === removed?.id) {
|
||||||
|
defaultId = providers[0]?.id ?? ""
|
||||||
|
}
|
||||||
|
return { ...prev, providers, default_provider_id: defaultId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await updateSettings.mutateAsync(draft)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
|
||||||
|
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
API OpenAI-compatibles pour l'enrichissement des contacts et le tri par règles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.providers.map((provider, index) => (
|
||||||
|
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeProvider(index)}
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Nom</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
value={provider.name}
|
||||||
|
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||||
|
placeholder="OpenAI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">URL de base</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
value={provider.base_url}
|
||||||
|
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Clé API</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
type="password"
|
||||||
|
value={provider.api_key ?? ""}
|
||||||
|
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Modèle par défaut</Label>
|
||||||
|
<LLMModelSuggestInput
|
||||||
|
className="mt-1"
|
||||||
|
baseUrl={provider.base_url}
|
||||||
|
apiKey={provider.api_key}
|
||||||
|
value={provider.default_model}
|
||||||
|
onChange={(default_model) => updateProvider(index, { default_model })}
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={addProvider}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Ajouter un fournisseur
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||||
|
<h4 className="text-sm font-medium">Découverte de contacts</h4>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.default_provider_id}
|
||||||
|
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-9">
|
||||||
|
<SelectValue placeholder="Choisir…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{draft.providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name || p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Modèle pour l'enrichissement</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-9">
|
||||||
|
<SelectValue placeholder="Même que défaut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{draft.providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name || p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Modèle LLM</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
value={draft.contact_discovery_model ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateSettings.isPending}
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
>
|
||||||
|
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
components/gmail/settings/automation/search-providers-panel.tsx
Normal file
123
components/gmail/settings/automation/search-providers-panel.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { ExternalLink } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
useSearchSettings,
|
||||||
|
useUpdateSearchSettings,
|
||||||
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
|
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
|
||||||
|
import {
|
||||||
|
CONTACTS_MUTED_TEXT,
|
||||||
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const BRAVE_PROVIDER_ID = "brave-default"
|
||||||
|
|
||||||
|
function defaultBraveProvider(): ApiSearchProvider {
|
||||||
|
return {
|
||||||
|
id: BRAVE_PROVIDER_ID,
|
||||||
|
name: "Brave Search",
|
||||||
|
type: "brave",
|
||||||
|
api_key: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDraft(raw: ApiSearchSettings | undefined): ApiSearchSettings {
|
||||||
|
const providers = raw?.providers?.length ? raw.providers : [defaultBraveProvider()]
|
||||||
|
const brave = providers.find((p) => p.type === "brave") ?? defaultBraveProvider()
|
||||||
|
return {
|
||||||
|
default_provider_id: raw?.default_provider_id || brave.id,
|
||||||
|
providers: [brave],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchProvidersPanel() {
|
||||||
|
const { data: remote, isLoading } = useSearchSettings()
|
||||||
|
const updateSettings = useUpdateSearchSettings()
|
||||||
|
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeDraft(undefined))
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remote) {
|
||||||
|
setDraft(normalizeDraft(remote))
|
||||||
|
}
|
||||||
|
}, [remote])
|
||||||
|
|
||||||
|
const brave = draft.providers[0] ?? defaultBraveProvider()
|
||||||
|
|
||||||
|
function updateBrave(patch: Partial<ApiSearchProvider>) {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const current = prev.providers[0] ?? defaultBraveProvider()
|
||||||
|
const updated = { ...current, ...patch }
|
||||||
|
return {
|
||||||
|
default_provider_id: updated.id,
|
||||||
|
providers: [updated],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await updateSettings.mutateAsync(draft)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
|
||||||
|
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Recherche web utilisée lors de l'amélioration IA des fiches contacts (profils
|
||||||
|
publics, réseaux sociaux, poste, entreprise).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{brave.name}</span>
|
||||||
|
<a
|
||||||
|
href="https://api.search.brave.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn("inline-flex items-center gap-1 text-xs hover:underline", CONTACTS_MUTED_TEXT)}
|
||||||
|
>
|
||||||
|
Obtenir une clé API
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Token API (X-Subscription-Token)</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
type="password"
|
||||||
|
value={brave.api_key ?? ""}
|
||||||
|
onChange={(e) => updateBrave({ api_key: e.target.value })}
|
||||||
|
placeholder="BSA…"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className={cn("mt-1.5 text-xs", CONTACTS_MUTED_TEXT)}>
|
||||||
|
Les 5 premiers résultats web sont ajoutés au prompt LLM avec un avertissement sur les
|
||||||
|
homonymes. Sans token, l'amélioration IA fonctionne sans recherche en ligne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateSettings.isPending}
|
||||||
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
|
>
|
||||||
|
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import { SettingsSectionHeader } from "@/components/gmail/settings/settings-sect
|
|||||||
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
|
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
|
||||||
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
|
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
|
||||||
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
||||||
|
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
|
||||||
|
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
|
||||||
|
|
||||||
export function AutomationSettingsSection() {
|
export function AutomationSettingsSection() {
|
||||||
return (
|
return (
|
||||||
@ -18,6 +20,7 @@ export function AutomationSettingsSection() {
|
|||||||
<TabsTrigger value="rules">Règles</TabsTrigger>
|
<TabsTrigger value="rules">Règles</TabsTrigger>
|
||||||
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||||
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
|
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
|
||||||
|
<TabsTrigger value="search">Fournisseurs de recherche</TabsTrigger>
|
||||||
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
|
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@ -28,10 +31,10 @@ export function AutomationSettingsSection() {
|
|||||||
<WebhooksPanel />
|
<WebhooksPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="llm" className="mt-4">
|
<TabsContent value="llm" className="mt-4">
|
||||||
<SettingsComingSoon
|
<LLMProvidersPanel />
|
||||||
title="Tri par LLM"
|
</TabsContent>
|
||||||
description="Configurez des fournisseurs OpenAI-compatibles. Les nœuds LLM utilisent un heuristique en attendant le branchement complet."
|
<TabsContent value="search" className="mt-4">
|
||||||
/>
|
<SearchProvidersPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="tokens" className="mt-4">
|
<TabsContent value="tokens" className="mt-4">
|
||||||
<SettingsComingSoon
|
<SettingsComingSoon
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import Script from 'next/script'
|
'use client'
|
||||||
|
|
||||||
/** Contenu exécuté avant hydratation (thème + fond, évite flash clair). */
|
/** Contenu exécuté avant hydratation (thème + fond, évite flash clair). */
|
||||||
export const THEME_INIT_SCRIPT = `
|
export const THEME_INIT_SCRIPT = `
|
||||||
@ -61,11 +61,23 @@ export const THEME_INIT_SCRIPT = `
|
|||||||
})();
|
})();
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
/** Script bloquant injecté par Next.js dans le <head> (compatible React 19). */
|
/**
|
||||||
|
* Script bloquant dans <head>. SSR rend script exécutable ; côté client type
|
||||||
|
* inerte pour éviter l'avertissement React 19 (le script a déjà tourné).
|
||||||
|
*/
|
||||||
export function ThemeInitScript() {
|
export function ThemeInitScript() {
|
||||||
|
const isServer = typeof window === 'undefined'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Script id="ultimail-theme-init" strategy="beforeInteractive">
|
<script
|
||||||
{THEME_INIT_SCRIPT}
|
id="ultimail-theme-init"
|
||||||
</Script>
|
suppressHydrationWarning
|
||||||
|
{...(isServer
|
||||||
|
? { dangerouslySetInnerHTML: { __html: THEME_INIT_SCRIPT } }
|
||||||
|
: {
|
||||||
|
type: 'application/json' as const,
|
||||||
|
dangerouslySetInnerHTML: { __html: THEME_INIT_SCRIPT },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,13 +12,15 @@ export type ContactsTableColumn =
|
|||||||
|
|
||||||
const COLUMN_WIDTHS: Record<ContactsTableColumn, string> = {
|
const COLUMN_WIDTHS: Record<ContactsTableColumn, string> = {
|
||||||
checkbox: "40px",
|
checkbox: "40px",
|
||||||
name: "minmax(0, 2fr)",
|
name: "minmax(0px, 2fr)",
|
||||||
email: "minmax(0, 2fr)",
|
email: "minmax(0px, 2fr)",
|
||||||
phone: "minmax(0, 1.5fr)",
|
phone: "minmax(0px, 1.5fr)",
|
||||||
job: "minmax(0, 1.5fr)",
|
job: "minmax(0px, 1.5fr)",
|
||||||
labels: "minmax(0, 1fr)",
|
labels: "minmax(0px, 1fr)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SSR_COLUMNS: ContactsTableColumn[] = ["checkbox", "name"]
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<Exclude<ContactsTableColumn, "checkbox">, string> = {
|
const COLUMN_LABELS: Record<Exclude<ContactsTableColumn, "checkbox">, string> = {
|
||||||
name: "Nom",
|
name: "Nom",
|
||||||
email: "E-mail",
|
email: "E-mail",
|
||||||
@ -36,11 +38,8 @@ function columnsForWidth(width: number): ContactsTableColumn[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useContactsTableColumns() {
|
export function useContactsTableColumns() {
|
||||||
const [visibleColumns, setVisibleColumns] = useState<ContactsTableColumn[]>(() =>
|
const [visibleColumns, setVisibleColumns] =
|
||||||
typeof window === "undefined"
|
useState<ContactsTableColumn[]>(SSR_COLUMNS)
|
||||||
? ["checkbox", "name", "email", "phone", "job", "labels"]
|
|
||||||
: columnsForWidth(window.innerWidth)
|
|
||||||
)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const update = () => setVisibleColumns(columnsForWidth(window.innerWidth))
|
const update = () => setVisibleColumns(columnsForWidth(window.innerWidth))
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { ApiContact } from './types'
|
import type { ApiContact } from './types'
|
||||||
import type { FullContact, ContactAddress } from '@/lib/contacts/types'
|
import type { FullContact, ContactAddress } from '@/lib/contacts/types'
|
||||||
|
import type { ContactBulkEditField } from '@/lib/contacts/bulk-edit-fields'
|
||||||
|
import {
|
||||||
|
avatarUrlToVCardPhotoLine,
|
||||||
|
parseVCardPhoto,
|
||||||
|
} from '@/lib/contact-avatar'
|
||||||
|
|
||||||
interface VCardFields {
|
interface VCardFields {
|
||||||
fn?: string
|
fn?: string
|
||||||
@ -10,11 +15,150 @@ interface VCardFields {
|
|||||||
bday?: string
|
bday?: string
|
||||||
note?: string
|
note?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
|
url?: string
|
||||||
|
socialProfiles: { value: string; type: string }[]
|
||||||
|
ultiLabels?: string[]
|
||||||
|
photo?: string
|
||||||
addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[]
|
addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VCARD_TYPE_MAP: Record<string, string> = {
|
||||||
|
travail: 'WORK',
|
||||||
|
work: 'WORK',
|
||||||
|
domicile: 'HOME',
|
||||||
|
home: 'HOME',
|
||||||
|
mobile: 'CELL',
|
||||||
|
cell: 'CELL',
|
||||||
|
autre: 'OTHER',
|
||||||
|
other: 'OTHER',
|
||||||
|
internet: 'INTERNET',
|
||||||
|
personal: 'HOME',
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeVCardValue(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/;/g, '\\;')
|
||||||
|
.replace(/,/g, '\\,')
|
||||||
|
.replace(/\r?\n/g, '\\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function vcardTypeParam(label: string): string {
|
||||||
|
const key = label.trim().toLowerCase()
|
||||||
|
return VCARD_TYPE_MAP[key] ?? 'OTHER'
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldVCardLine(line: string): string {
|
||||||
|
const max = 75
|
||||||
|
if (line.length <= max) return line
|
||||||
|
const parts: string[] = [line.slice(0, max)]
|
||||||
|
let rest = line.slice(max)
|
||||||
|
while (rest.length > 0) {
|
||||||
|
parts.push(` ${rest.slice(0, max - 1)}`)
|
||||||
|
rest = rest.slice(max - 1)
|
||||||
|
}
|
||||||
|
return parts.join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function vcardSocialTypeParam(label: string): string {
|
||||||
|
const key = label.trim().toLowerCase()
|
||||||
|
if (key === 'x') return 'twitter'
|
||||||
|
return key || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function socialProfileLabelFromType(type: string): string {
|
||||||
|
const key = type.trim().toLowerCase()
|
||||||
|
if (key === 'x') return 'twitter'
|
||||||
|
return key || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVCardFromFullContact(contact: FullContact): string {
|
||||||
|
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0']
|
||||||
|
const uid = contact.id?.trim()
|
||||||
|
if (uid) lines.push(`UID:${escapeVCardValue(uid)}`)
|
||||||
|
|
||||||
|
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
|
||||||
|
lines.push(
|
||||||
|
`N:${escapeVCardValue(contact.lastName)};${escapeVCardValue(contact.firstName)};;;`,
|
||||||
|
)
|
||||||
|
lines.push(`FN:${escapeVCardValue(fullName || contact.emails[0]?.value || 'Contact')}`)
|
||||||
|
|
||||||
|
for (const email of contact.emails) {
|
||||||
|
const value = email.value?.trim()
|
||||||
|
if (!value) continue
|
||||||
|
const type = vcardTypeParam(email.label)
|
||||||
|
lines.push(`EMAIL;TYPE=${type}:${escapeVCardValue(value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phone of contact.phones) {
|
||||||
|
const value = phone.value?.trim()
|
||||||
|
if (!value) continue
|
||||||
|
const type = vcardTypeParam(phone.label)
|
||||||
|
lines.push(`TEL;TYPE=${type}:${escapeVCardValue(value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.company?.trim() || contact.department?.trim()) {
|
||||||
|
const orgParts = [contact.company?.trim() ?? '', contact.department?.trim() ?? '']
|
||||||
|
lines.push(`ORG:${orgParts.map(escapeVCardValue).join(';')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.jobTitle?.trim()) {
|
||||||
|
lines.push(`TITLE:${escapeVCardValue(contact.jobTitle.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.website?.trim()) {
|
||||||
|
lines.push(`URL:${escapeVCardValue(contact.website.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of contact.socialProfiles ?? []) {
|
||||||
|
const value = profile.value?.trim()
|
||||||
|
if (!value) continue
|
||||||
|
const type = vcardSocialTypeParam(profile.label)
|
||||||
|
lines.push(`X-SOCIALPROFILE;TYPE=${type}:${escapeVCardValue(value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const addr of contact.addresses ?? []) {
|
||||||
|
const hasValue =
|
||||||
|
addr.street?.trim() ||
|
||||||
|
addr.city?.trim() ||
|
||||||
|
addr.region?.trim() ||
|
||||||
|
addr.postalCode?.trim() ||
|
||||||
|
addr.country?.trim()
|
||||||
|
if (!hasValue) continue
|
||||||
|
const type = vcardTypeParam(addr.label)
|
||||||
|
const adr = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
addr.street?.trim() ?? '',
|
||||||
|
addr.city?.trim() ?? '',
|
||||||
|
addr.region?.trim() ?? '',
|
||||||
|
addr.postalCode?.trim() ?? '',
|
||||||
|
addr.country?.trim() ?? '',
|
||||||
|
]
|
||||||
|
.map(escapeVCardValue)
|
||||||
|
.join(';')
|
||||||
|
lines.push(`ADR;TYPE=${type}:${adr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.notes?.trim()) {
|
||||||
|
lines.push(`NOTE:${escapeVCardValue(contact.notes.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.labels?.length) {
|
||||||
|
lines.push(
|
||||||
|
`X-ULTI-LABELS:${contact.labels.map(escapeVCardValue).join(',')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoLine = avatarUrlToVCardPhotoLine(contact.avatarUrl, escapeVCardValue)
|
||||||
|
if (photoLine) lines.push(photoLine)
|
||||||
|
|
||||||
|
lines.push('END:VCARD')
|
||||||
|
return lines.map(foldVCardLine).join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
function parseVCard(raw: string): VCardFields {
|
function parseVCard(raw: string): VCardFields {
|
||||||
const fields: VCardFields = { emails: [], phones: [], addresses: [] }
|
const fields: VCardFields = { emails: [], phones: [], addresses: [], socialProfiles: [] }
|
||||||
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (const line of raw.split(/\r?\n/)) {
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
@ -61,6 +205,13 @@ function parseVCard(raw: string): VCardFields {
|
|||||||
case 'NOTE':
|
case 'NOTE':
|
||||||
fields.note = value
|
fields.note = value
|
||||||
break
|
break
|
||||||
|
case 'URL':
|
||||||
|
fields.url = value
|
||||||
|
break
|
||||||
|
case 'X-SOCIALPROFILE':
|
||||||
|
case 'SOCIALPROFILE':
|
||||||
|
fields.socialProfiles.push({ value, type: socialProfileLabelFromType(type) })
|
||||||
|
break
|
||||||
case 'NICKNAME':
|
case 'NICKNAME':
|
||||||
fields.nickname = value
|
fields.nickname = value
|
||||||
break
|
break
|
||||||
@ -76,6 +227,15 @@ function parseVCard(raw: string): VCardFields {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'X-ULTI-LABELS':
|
||||||
|
fields.ultiLabels = value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
break
|
||||||
|
case 'PHOTO':
|
||||||
|
fields.photo = parseVCardPhoto(rawKey, value) ?? fields.photo
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +292,8 @@ export function apiContactToFullContact(api: ApiContact): FullContact {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: api.uid,
|
id: api.uid,
|
||||||
|
path: api.path,
|
||||||
|
etag: api.etag,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
emails,
|
emails,
|
||||||
@ -139,20 +301,177 @@ export function apiContactToFullContact(api: ApiContact): FullContact {
|
|||||||
addresses,
|
addresses,
|
||||||
company: vcard?.org ?? api.org,
|
company: vcard?.org ?? api.org,
|
||||||
jobTitle: vcard?.title,
|
jobTitle: vcard?.title,
|
||||||
|
website: vcard?.url,
|
||||||
|
socialProfiles: vcard?.socialProfiles.length
|
||||||
|
? vcard.socialProfiles.map((p) => ({ value: p.value, label: p.type }))
|
||||||
|
: undefined,
|
||||||
birthday,
|
birthday,
|
||||||
notes: vcard?.note,
|
notes: vcard?.note,
|
||||||
nicknames: vcard?.nickname ? [vcard.nickname] : undefined,
|
nicknames: vcard?.nickname ? [vcard.nickname] : undefined,
|
||||||
|
labels: vcard?.ultiLabels?.length ? vcard.ultiLabels : undefined,
|
||||||
|
avatarUrl: vcard?.photo,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fullContactToApiContact(contact: FullContact): Partial<ApiContact> {
|
export function fullContactToApiContact(contact: FullContact): Partial<ApiContact> {
|
||||||
|
const raw_vcard = buildVCardFromFullContact(contact)
|
||||||
|
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
|
||||||
return {
|
return {
|
||||||
uid: contact.id,
|
uid: contact.id,
|
||||||
full_name: `${contact.firstName} ${contact.lastName}`.trim(),
|
full_name: fullName || contact.emails[0]?.value || 'Contact',
|
||||||
email: contact.emails[0]?.value,
|
email: contact.emails[0]?.value,
|
||||||
phone: contact.phones[0]?.value,
|
phone: contact.phones[0]?.value,
|
||||||
org: contact.company,
|
org: contact.company,
|
||||||
|
raw_vcard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unfoldVCardLines(raw: string): string[] {
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
if (/^\s/.test(line) && lines.length > 0) {
|
||||||
|
lines[lines.length - 1] += line.trimStart()
|
||||||
|
} else {
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
function vcardPropName(line: string): string {
|
||||||
|
return line.split(':')[0]?.split(';')[0]?.toUpperCase() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertVCardLine(
|
||||||
|
lines: string[],
|
||||||
|
propName: string,
|
||||||
|
lineContent: string | null,
|
||||||
|
): string[] {
|
||||||
|
const upper = propName.toUpperCase()
|
||||||
|
const kept = lines.filter((line) => vcardPropName(line) !== upper)
|
||||||
|
if (!lineContent) return kept
|
||||||
|
const endIdx = kept.findIndex((l) => l.toUpperCase() === 'END:VCARD')
|
||||||
|
const insertAt = endIdx >= 0 ? endIdx : kept.length
|
||||||
|
kept.splice(insertAt, 0, lineContent)
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOrgParts(vcard: string): { company: string; department: string } {
|
||||||
|
for (const line of unfoldVCardLines(vcard)) {
|
||||||
|
if (!line.toUpperCase().startsWith('ORG:')) continue
|
||||||
|
const value = line.slice(line.indexOf(':') + 1)
|
||||||
|
const parts = value.split(';')
|
||||||
|
return {
|
||||||
|
company: (parts[0] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'),
|
||||||
|
department: (parts[1] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { company: '', department: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch an existing vCard in-place to avoid losing unparsed fields on bulk edit. */
|
||||||
|
export function patchVCardBulkField(
|
||||||
|
rawVCard: string,
|
||||||
|
contact: FullContact,
|
||||||
|
field: ContactBulkEditField,
|
||||||
|
value: string,
|
||||||
|
): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
let lines = unfoldVCardLines(rawVCard)
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'company': {
|
||||||
|
const org = readOrgParts(rawVCard)
|
||||||
|
const company = trimmed
|
||||||
|
const department = contact.department?.trim() ?? org.department
|
||||||
|
const orgLine =
|
||||||
|
company || department
|
||||||
|
? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}`
|
||||||
|
: null
|
||||||
|
lines = upsertVCardLine(lines, 'ORG', orgLine)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'department': {
|
||||||
|
const org = readOrgParts(rawVCard)
|
||||||
|
const company = contact.company?.trim() ?? org.company
|
||||||
|
const department = trimmed
|
||||||
|
const orgLine =
|
||||||
|
company || department
|
||||||
|
? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}`
|
||||||
|
: null
|
||||||
|
lines = upsertVCardLine(lines, 'ORG', orgLine)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'jobTitle':
|
||||||
|
lines = upsertVCardLine(
|
||||||
|
lines,
|
||||||
|
'TITLE',
|
||||||
|
trimmed ? `TITLE:${escapeVCardValue(trimmed)}` : null,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'website':
|
||||||
|
lines = upsertVCardLine(
|
||||||
|
lines,
|
||||||
|
'URL',
|
||||||
|
trimmed ? `URL:${escapeVCardValue(trimmed)}` : null,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'notes':
|
||||||
|
lines = upsertVCardLine(
|
||||||
|
lines,
|
||||||
|
'NOTE',
|
||||||
|
trimmed ? `NOTE:${escapeVCardValue(trimmed)}` : null,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.map(foldVCardLine).join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchVCardPhoto(rawVCard: string, avatarUrl: string | undefined): string {
|
||||||
|
const lines = unfoldVCardLines(rawVCard)
|
||||||
|
const lineContent = avatarUrlToVCardPhotoLine(avatarUrl, escapeVCardValue)
|
||||||
|
const next = upsertVCardLine(lines, 'PHOTO', lineContent)
|
||||||
|
return next.map(foldVCardLine).join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchVCardLabels(rawVCard: string, labelIds: string[] | undefined): string {
|
||||||
|
const lines = unfoldVCardLines(rawVCard)
|
||||||
|
const lineContent =
|
||||||
|
labelIds?.length
|
||||||
|
? `X-ULTI-LABELS:${labelIds.map(escapeVCardValue).join(',')}`
|
||||||
|
: null
|
||||||
|
const next = upsertVCardLine(lines, 'X-ULTI-LABELS', lineContent)
|
||||||
|
return next.map(foldVCardLine).join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContactUpdatePayload(
|
||||||
|
existing: ApiContact | undefined,
|
||||||
|
contact: FullContact,
|
||||||
|
opts?: {
|
||||||
|
bulkField?: ContactBulkEditField
|
||||||
|
bulkValue?: string
|
||||||
|
patchLabels?: boolean
|
||||||
|
},
|
||||||
|
): Partial<ApiContact> {
|
||||||
|
const base = fullContactToApiContact(contact)
|
||||||
|
const raw = existing?.raw_vcard?.trim()
|
||||||
|
|
||||||
|
if (raw && opts?.bulkField !== undefined) {
|
||||||
|
const patched = patchVCardBulkField(raw, contact, opts.bulkField, opts.bulkValue ?? '')
|
||||||
|
return { ...base, raw_vcard: patched }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw && opts?.patchLabels) {
|
||||||
|
return { ...base, raw_vcard: patchVCardLabels(raw, contact.labels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
const patched = patchVCardPhoto(raw, contact.avatarUrl)
|
||||||
|
return { ...base, raw_vcard: patched }
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|||||||
@ -83,6 +83,7 @@ class ApiClient {
|
|||||||
opts?: {
|
opts?: {
|
||||||
body?: unknown
|
body?: unknown
|
||||||
params?: Record<string, string | undefined>
|
params?: Record<string, string | undefined>
|
||||||
|
headers?: Record<string, string>
|
||||||
timeout?: number
|
timeout?: number
|
||||||
retries?: number
|
retries?: number
|
||||||
}
|
}
|
||||||
@ -118,7 +119,7 @@ class ApiClient {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
method,
|
method,
|
||||||
headers: this.getHeaders(),
|
headers: { ...this.getHeaders(), ...opts?.headers },
|
||||||
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@ -211,8 +212,12 @@ class ApiClient {
|
|||||||
return this.request<T>("POST", path, { body })
|
return this.request<T>("POST", path, { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
async put<T>(
|
||||||
return this.request<T>("PUT", path, { body })
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.request<T>("PUT", path, { body, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
|||||||
80
lib/api/contact-book-cache.ts
Normal file
80
lib/api/contact-book-cache.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import type { ApiContact } from '@/lib/api/types'
|
||||||
|
import { contactApiPath } from '@/lib/contacts/contact-api-path'
|
||||||
|
import type { FullContact } from '@/lib/contacts/types'
|
||||||
|
|
||||||
|
function contactMatchesPath(api: ApiContact, apiPath: string): boolean {
|
||||||
|
const normalizedPath = apiPath.replace(/^\/+/, '')
|
||||||
|
const contactPath = api.path?.replace(/^\/+/, '')
|
||||||
|
return (
|
||||||
|
contactPath === normalizedPath ||
|
||||||
|
api.uid === normalizedPath ||
|
||||||
|
contactPath?.endsWith(`/${normalizedPath}`) === true ||
|
||||||
|
contactPath?.endsWith(`/${normalizedPath}.vcf`) === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge selection snapshot with latest React Query cache (etag, path). */
|
||||||
|
export function resolveContactForUpdate(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
contact: FullContact,
|
||||||
|
): FullContact {
|
||||||
|
const api = findApiContactInCaches(queryClient, contact)
|
||||||
|
if (!api) return contact
|
||||||
|
const etag = contact.etag ?? api.etag
|
||||||
|
const path = contact.path ?? api.path
|
||||||
|
if (etag === contact.etag && path === contact.path) return contact
|
||||||
|
return { ...contact, etag, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findApiContactInCaches(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
contact: Pick<FullContact, 'id' | 'path'>,
|
||||||
|
): ApiContact | undefined {
|
||||||
|
const apiPath = contactApiPath(contact)
|
||||||
|
for (const [, list] of queryClient.getQueriesData<ApiContact[]>({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
})) {
|
||||||
|
if (!list) continue
|
||||||
|
const hit = list.find(
|
||||||
|
(c) => c.uid === contact.id || contactMatchesPath(c, apiPath),
|
||||||
|
)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendContactToBookCache(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
bookId: string,
|
||||||
|
contact: ApiContact,
|
||||||
|
) {
|
||||||
|
if (!contact.uid && !contact.path) return
|
||||||
|
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
|
||||||
|
const list = old ?? []
|
||||||
|
const uid = contact.uid
|
||||||
|
if (uid && list.some((item) => item.uid === uid)) return list
|
||||||
|
return [...list, contact]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceContactInBookCaches(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
apiPath: string,
|
||||||
|
patch: Partial<ApiContact>,
|
||||||
|
) {
|
||||||
|
for (const [key, list] of queryClient.getQueriesData<ApiContact[]>({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
})) {
|
||||||
|
if (!list) continue
|
||||||
|
let changed = false
|
||||||
|
const next = list.map((item) => {
|
||||||
|
if (!contactMatchesPath(item, apiPath)) return item
|
||||||
|
changed = true
|
||||||
|
return { ...item, ...patch }
|
||||||
|
})
|
||||||
|
if (changed) {
|
||||||
|
queryClient.setQueryData(key, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/api/contact-list-cache.ts
Normal file
99
lib/api/contact-list-cache.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { apiContactToFullContact } from '@/lib/api/adapters'
|
||||||
|
import type { ApiContact } from '@/lib/api/types'
|
||||||
|
import type { FullContact } from '@/lib/contacts/types'
|
||||||
|
|
||||||
|
type BookListCache = {
|
||||||
|
apiContacts: ApiContact[]
|
||||||
|
fullContacts: FullContact[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCache = new Map<string, FullContact>()
|
||||||
|
const parseSigCache = new Map<string, string>()
|
||||||
|
const listCacheByBook = new Map<string, BookListCache>()
|
||||||
|
|
||||||
|
function contactCacheKey(api: ApiContact): string {
|
||||||
|
return api.uid || api.path || `${api.full_name}:${api.email ?? ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactSignature(api: ApiContact): string {
|
||||||
|
return [
|
||||||
|
api.uid,
|
||||||
|
api.path,
|
||||||
|
api.etag,
|
||||||
|
api.full_name,
|
||||||
|
api.email,
|
||||||
|
api.raw_vcard?.length ?? 0,
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse vCard une seule fois par contact tant que la signature API est stable. */
|
||||||
|
export function apiContactToFullContactCached(api: ApiContact): FullContact {
|
||||||
|
const key = contactCacheKey(api)
|
||||||
|
const sig = contactSignature(api)
|
||||||
|
if (parseSigCache.get(key) === sig) {
|
||||||
|
const hit = parseCache.get(key)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
const full = apiContactToFullContact(api)
|
||||||
|
parseCache.set(key, full)
|
||||||
|
parseSigCache.set(key, sig)
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildFullList(bookId: string, apiContacts: ApiContact[]): FullContact[] {
|
||||||
|
const fullContacts = apiContacts.map(apiContactToFullContactCached)
|
||||||
|
listCacheByBook.set(bookId, { apiContacts, fullContacts })
|
||||||
|
return fullContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruit la liste complète seulement si nécessaire.
|
||||||
|
* Sur ajout en fin de liste (cas courant), réutilise les contacts déjà parsés.
|
||||||
|
*/
|
||||||
|
export function mapApiContactsToFullContacts(
|
||||||
|
bookId: string,
|
||||||
|
apiContacts: ApiContact[] | undefined,
|
||||||
|
): FullContact[] {
|
||||||
|
if (!apiContacts) return []
|
||||||
|
|
||||||
|
const prev = listCacheByBook.get(bookId)
|
||||||
|
if (prev && prev.apiContacts === apiContacts) {
|
||||||
|
return prev.fullContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev && apiContacts.length === prev.apiContacts.length + 1) {
|
||||||
|
let prefixMatch = true
|
||||||
|
for (let i = 0; i < prev.apiContacts.length; i++) {
|
||||||
|
if (apiContacts[i] !== prev.apiContacts[i]) {
|
||||||
|
prefixMatch = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prefixMatch) {
|
||||||
|
const appended = apiContactToFullContactCached(apiContacts[apiContacts.length - 1]!)
|
||||||
|
const fullContacts = [...prev.fullContacts, appended]
|
||||||
|
listCacheByBook.set(bookId, { apiContacts, fullContacts })
|
||||||
|
return fullContacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
apiContacts.length === prev.apiContacts.length &&
|
||||||
|
apiContacts.every((c, i) => c === prev.apiContacts[i])
|
||||||
|
) {
|
||||||
|
return prev.fullContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
return rebuildFullList(bookId, apiContacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateContactListCache(bookId?: string) {
|
||||||
|
if (bookId) {
|
||||||
|
listCacheByBook.delete(bookId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listCacheByBook.clear()
|
||||||
|
parseCache.clear()
|
||||||
|
parseSigCache.clear()
|
||||||
|
}
|
||||||
855
lib/api/hooks/use-contact-discovery.ts
Normal file
855
lib/api/hooks/use-contact-discovery.ts
Normal file
@ -0,0 +1,855 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type InfiniteData,
|
||||||
|
type QueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { appendContactToBookCache } from '../contact-book-cache'
|
||||||
|
import { apiClient, ApiRequestError, OfflineError } from '../client'
|
||||||
|
import { enqueue } from '../offline-queue'
|
||||||
|
import type { ApiContact } from '../types'
|
||||||
|
import type {
|
||||||
|
ApiDiscoveryCounts,
|
||||||
|
ApiDiscoveryScan,
|
||||||
|
ApiDiscoveredProfile,
|
||||||
|
ApiDiscoveredProfileGroup,
|
||||||
|
ApiDispositionEmails,
|
||||||
|
ApiEnrichmentSuggestion,
|
||||||
|
ApiLLMModelsResponse,
|
||||||
|
ApiLLMSettings,
|
||||||
|
ApiSearchSettings,
|
||||||
|
} from '@/lib/contacts/discovery-types'
|
||||||
|
import {
|
||||||
|
coerceDiscoveryGroups,
|
||||||
|
dedupeDiscoveryGroups,
|
||||||
|
discoveryGroupReactKey,
|
||||||
|
parseDiscoveryOtherPage,
|
||||||
|
parseDiscoveryOtherResponse,
|
||||||
|
type DiscoveryOtherPageResult,
|
||||||
|
} from '@/lib/contacts/discovery-grouping'
|
||||||
|
import { filterVisibleEnrichmentSuggestions } from '@/lib/contacts/discovery-utils'
|
||||||
|
import { useContactsList } from '@/lib/contacts/use-contacts-list'
|
||||||
|
|
||||||
|
const ACTIVE_SCAN_STORAGE_KEY = 'ultimail-contact-discovery-scan-id'
|
||||||
|
|
||||||
|
export function getPersistedScanId(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return localStorage.getItem(ACTIVE_SCAN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistScanId(scanId: string | null) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (scanId) {
|
||||||
|
localStorage.setItem(ACTIVE_SCAN_STORAGE_KEY, scanId)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ACTIVE_SCAN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryCounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-counts'],
|
||||||
|
queryFn: () => apiClient.get<ApiDiscoveryCounts>('/contacts/discovery/counts'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OTHER_CONTACTS_PAGE_SIZE = 12
|
||||||
|
|
||||||
|
const OTHER_CONTACTS_QUERY_KEY = ['contact-discovery-other', 'groups', 'infinite'] as const
|
||||||
|
|
||||||
|
function otherContactsQueryKey(searchQuery: string) {
|
||||||
|
const q = searchQuery.trim()
|
||||||
|
return q ? ([...OTHER_CONTACTS_QUERY_KEY, q] as const) : OTHER_CONTACTS_QUERY_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache session si l'API renvoie encore toute la liste d'un coup (legacy). */
|
||||||
|
let legacyOtherContactsFullList: ApiDiscoveredProfileGroup[] | null = null
|
||||||
|
|
||||||
|
function sliceLegacyOtherContactsPage(offset: number): DiscoveryOtherPageResult | null {
|
||||||
|
if (!legacyOtherContactsFullList?.length) return null
|
||||||
|
const groups = legacyOtherContactsFullList.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
total: legacyOtherContactsFullList.length,
|
||||||
|
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < legacyOtherContactsFullList.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenOtherContactPages(
|
||||||
|
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
|
||||||
|
): ApiDiscoveredProfileGroup[] {
|
||||||
|
if (!data?.pages?.length) return []
|
||||||
|
return dedupeDiscoveryGroups(data.pages.flatMap((page) => page.groups ?? []))
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeOtherContactsInfiniteCache(
|
||||||
|
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
|
||||||
|
): InfiniteData<DiscoveryOtherPageResult> | undefined {
|
||||||
|
if (!data?.pages?.length) return data
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const pages = data.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
groups: dedupeDiscoveryGroups(page.groups ?? []).filter((group) => {
|
||||||
|
const key = discoveryGroupReactKey(group)
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
return { ...data, pages }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOtherDiscoveredContacts(enabled = true, searchQuery = '') {
|
||||||
|
const trimmedSearch = searchQuery.trim()
|
||||||
|
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: otherContactsQueryKey(trimmedSearch),
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const offset = typeof pageParam === 'number' ? pageParam : 0
|
||||||
|
const cachedLegacy = offset > 0 && !trimmedSearch ? sliceLegacyOtherContactsPage(offset) : null
|
||||||
|
if (cachedLegacy) {
|
||||||
|
return {
|
||||||
|
groups: coerceDiscoveryGroups(cachedLegacy.groups),
|
||||||
|
total: cachedLegacy.total,
|
||||||
|
hasMore: cachedLegacy.hasMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.get<{
|
||||||
|
groups?: ApiDiscoveredProfileGroup[]
|
||||||
|
profiles?: ApiDiscoveredProfile[]
|
||||||
|
total?: number
|
||||||
|
has_more?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}>('/contacts/discovery/other', {
|
||||||
|
limit: String(OTHER_CONTACTS_PAGE_SIZE),
|
||||||
|
offset: String(offset),
|
||||||
|
...(trimmedSearch ? { q: trimmedSearch } : {}),
|
||||||
|
})
|
||||||
|
const allGroups = coerceDiscoveryGroups(parseDiscoveryOtherResponse(res))
|
||||||
|
const serverPaginated =
|
||||||
|
('has_more' in res && res.has_more != null) || typeof res.total === 'number'
|
||||||
|
|
||||||
|
if (!serverPaginated && allGroups.length > OTHER_CONTACTS_PAGE_SIZE) {
|
||||||
|
legacyOtherContactsFullList = allGroups
|
||||||
|
const groups = allGroups.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
total: allGroups.length,
|
||||||
|
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < allGroups.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyOtherContactsFullList = null
|
||||||
|
const page = parseDiscoveryOtherPage(res, {
|
||||||
|
offset,
|
||||||
|
pageSize: OTHER_CONTACTS_PAGE_SIZE,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
groups: coerceDiscoveryGroups(page.groups ?? []),
|
||||||
|
total: page.total,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
if (!lastPage?.hasMore) return undefined
|
||||||
|
return allPages.reduce((sum, p) => sum + (p.groups?.length ?? 0), 0)
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const groups = flattenOtherContactPages(query.state.data)
|
||||||
|
return groups.some((g) => g.profile?.enrichment_status === 'enriching') ? 2000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIgnoredDiscoveredContacts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-ignored'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
|
||||||
|
'/contacts/discovery/ignored',
|
||||||
|
)
|
||||||
|
return res.profiles ?? []
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlockedDiscoveredContacts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-blocked'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
|
||||||
|
'/contacts/discovery/blocked',
|
||||||
|
)
|
||||||
|
return res.profiles ?? []
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnrichmentSuggestions(type?: 'enrich' | 'all') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-suggestions', type],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ suggestions: ApiEnrichmentSuggestion[] }>(
|
||||||
|
'/contacts/discovery/suggestions',
|
||||||
|
type === 'enrich' ? { type: 'enrich' } : undefined,
|
||||||
|
)
|
||||||
|
return res.suggestions ?? []
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enrichment suggestions for existing contacts, excluding values already on the contact. */
|
||||||
|
export function useVisibleEnrichmentSuggestions() {
|
||||||
|
const { contacts } = useContactsList()
|
||||||
|
const query = useEnrichmentSuggestions('enrich')
|
||||||
|
const suggestions = useMemo(
|
||||||
|
() => filterVisibleEnrichmentSuggestions(query.data ?? [], contacts),
|
||||||
|
[query.data, contacts],
|
||||||
|
)
|
||||||
|
return { ...query, suggestions }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveDiscoveryScan() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-scan-active'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ scan: ApiDiscoveryScan | null }>(
|
||||||
|
'/contacts/discovery/scan/active',
|
||||||
|
)
|
||||||
|
const scan = res.scan ?? null
|
||||||
|
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
|
||||||
|
persistScanId(scan.id)
|
||||||
|
} else if (!scan) {
|
||||||
|
persistScanId(null)
|
||||||
|
}
|
||||||
|
return scan
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const scan = query.state.data
|
||||||
|
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
|
||||||
|
return 2000
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryScan(scanId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contact-discovery-scan', scanId],
|
||||||
|
queryFn: () => apiClient.get<ApiDiscoveryScan>(`/contacts/discovery/scan/${scanId}`),
|
||||||
|
enabled: !!scanId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.status
|
||||||
|
return status === 'running' || status === 'pending' ? 2000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartDiscoveryScan() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (bookId?: string) => {
|
||||||
|
const path = bookId
|
||||||
|
? `/contacts/discovery/scan?book_id=${encodeURIComponent(bookId)}`
|
||||||
|
: '/contacts/discovery/scan'
|
||||||
|
return apiClient.post<ApiDiscoveryScan>(path)
|
||||||
|
},
|
||||||
|
onSuccess: (scan) => {
|
||||||
|
persistScanId(scan.id)
|
||||||
|
queryClient.setQueryData(['contact-discovery-scan-active'], scan)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan', scan.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-ignored'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-blocked'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelDiscoveryScan() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => apiClient.post<void>('/contacts/discovery/scan/cancel'),
|
||||||
|
onSuccess: () => {
|
||||||
|
persistScanId(null)
|
||||||
|
queryClient.setQueryData(['contact-discovery-scan-active'], null)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan-active'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMatchesProfileId(group: ApiDiscoveredProfileGroup, profileId: string): boolean {
|
||||||
|
if (group.profile?.id === profileId) return true
|
||||||
|
if (group.profile_ids?.includes(profileId)) return true
|
||||||
|
return group.profiles?.some((p) => p.id === profileId) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOtherContactGroupInCache(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
profileId: string,
|
||||||
|
): ApiDiscoveredProfileGroup | undefined {
|
||||||
|
const data = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
|
||||||
|
OTHER_CONTACTS_QUERY_KEY,
|
||||||
|
)
|
||||||
|
if (!data?.pages?.length) return undefined
|
||||||
|
for (const page of data.pages) {
|
||||||
|
for (const group of page.groups ?? []) {
|
||||||
|
if (groupMatchesProfileId(group, profileId)) return group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emails liés à un groupe découvert (pour blocage optimiste côté client). */
|
||||||
|
export function extractDiscoveryGroupEmails(group: ApiDiscoveredProfileGroup): string[] {
|
||||||
|
const emails = new Set<string>()
|
||||||
|
if (group.primary_email) emails.add(group.primary_email.toLowerCase())
|
||||||
|
for (const profile of group.profiles ?? (group.profile ? [group.profile] : [])) {
|
||||||
|
if (profile.primary_email) emails.add(profile.primary_email.toLowerCase())
|
||||||
|
for (const entry of profile.all_emails ?? []) {
|
||||||
|
if (entry.email) emails.add(entry.email.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...emails]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOtherContactGroupFromCache(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
profileId: string,
|
||||||
|
): boolean {
|
||||||
|
let removed = false
|
||||||
|
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
|
||||||
|
OTHER_CONTACTS_QUERY_KEY,
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old
|
||||||
|
let didRemove = false
|
||||||
|
const pages = old.pages.map((page) => {
|
||||||
|
const groups = (page.groups ?? []).filter((group) => {
|
||||||
|
if (groupMatchesProfileId(group, profileId)) {
|
||||||
|
didRemove = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { ...page, groups }
|
||||||
|
})
|
||||||
|
if (didRemove && pages[0]) {
|
||||||
|
removed = true
|
||||||
|
pages[0] = {
|
||||||
|
...pages[0],
|
||||||
|
total: Math.max(0, pages[0].total - 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...old, pages }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (removed) {
|
||||||
|
if (legacyOtherContactsFullList) {
|
||||||
|
legacyOtherContactsFullList = legacyOtherContactsFullList.filter(
|
||||||
|
(group) => !groupMatchesProfileId(group, profileId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
other_contacts: Math.max(0, old.other_contacts - 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptimisticDispositionContext {
|
||||||
|
previous: InfiniteData<DiscoveryOtherPageResult> | undefined
|
||||||
|
previousCounts: ApiDiscoveryCounts | undefined
|
||||||
|
previousIgnored: ApiDiscoveredProfile[] | undefined
|
||||||
|
previousBlocked: ApiDiscoveredProfile[] | undefined
|
||||||
|
removedGroup: ApiDiscoveredProfileGroup | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollbackDispositionContext(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
context: OptimisticDispositionContext | undefined,
|
||||||
|
) {
|
||||||
|
if (!context) return
|
||||||
|
if (context.previous) {
|
||||||
|
queryClient.setQueryData(OTHER_CONTACTS_QUERY_KEY, context.previous)
|
||||||
|
}
|
||||||
|
if (context.previousCounts) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
|
||||||
|
}
|
||||||
|
if (context.previousIgnored) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
|
||||||
|
}
|
||||||
|
if (context.previousBlocked) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticRemoveFromDispositionLists(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
profileId: string,
|
||||||
|
): Pick<
|
||||||
|
OptimisticDispositionContext,
|
||||||
|
'previousIgnored' | 'previousBlocked' | 'previousCounts'
|
||||||
|
> {
|
||||||
|
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
|
||||||
|
'contact-discovery-ignored',
|
||||||
|
])
|
||||||
|
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
|
||||||
|
'contact-discovery-blocked',
|
||||||
|
])
|
||||||
|
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
|
||||||
|
'contact-discovery-counts',
|
||||||
|
])
|
||||||
|
|
||||||
|
let removedIgnored = false
|
||||||
|
let removedBlocked = false
|
||||||
|
|
||||||
|
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
const next = old.filter((p) => p.id !== profileId)
|
||||||
|
removedIgnored = next.length !== old.length
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
const next = old.filter((p) => p.id !== profileId)
|
||||||
|
removedBlocked = next.length !== old.length
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
if (removedIgnored || removedBlocked) {
|
||||||
|
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
ignored: removedIgnored ? Math.max(0, old.ignored - 1) : old.ignored,
|
||||||
|
blocked: removedBlocked ? Math.max(0, old.blocked - 1) : old.blocked,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousIgnored, previousBlocked, previousCounts }
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticRemoveOtherContact(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
profileId: string,
|
||||||
|
): OptimisticDispositionContext {
|
||||||
|
const previous = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
|
||||||
|
OTHER_CONTACTS_QUERY_KEY,
|
||||||
|
)
|
||||||
|
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
|
||||||
|
'contact-discovery-counts',
|
||||||
|
])
|
||||||
|
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
|
||||||
|
'contact-discovery-ignored',
|
||||||
|
])
|
||||||
|
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
|
||||||
|
'contact-discovery-blocked',
|
||||||
|
])
|
||||||
|
const removedGroup = findOtherContactGroupInCache(queryClient, profileId)
|
||||||
|
removeOtherContactGroupFromCache(queryClient, profileId)
|
||||||
|
void queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
|
||||||
|
return {
|
||||||
|
previous,
|
||||||
|
previousCounts,
|
||||||
|
previousIgnored,
|
||||||
|
previousBlocked,
|
||||||
|
removedGroup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependIgnoredProfile(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
group: ApiDiscoveredProfileGroup | undefined,
|
||||||
|
) {
|
||||||
|
const profile = group?.profile ?? group?.profiles?.[0]
|
||||||
|
if (!profile) return
|
||||||
|
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
|
||||||
|
const list = old ?? []
|
||||||
|
if (list.some((p) => p.id === profile.id)) return list
|
||||||
|
return [{ ...profile, status: 'ignored' as const }, ...list]
|
||||||
|
})
|
||||||
|
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return { ...old, ignored: old.ignored + 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependBlockedProfile(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
group: ApiDiscoveredProfileGroup | undefined,
|
||||||
|
) {
|
||||||
|
const profile = group?.profile ?? group?.profiles?.[0]
|
||||||
|
if (!profile) return
|
||||||
|
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
|
||||||
|
const list = old ?? []
|
||||||
|
if (list.some((p) => p.id === profile.id)) return list
|
||||||
|
return [{ ...profile, status: 'blocked' as const }, ...list]
|
||||||
|
})
|
||||||
|
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return { ...old, blocked: old.blocked + 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcceptDiscoveredProfile() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { profileId: string; contactUid?: string }) =>
|
||||||
|
apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
|
||||||
|
contact_uid: vars.contactUid ?? '',
|
||||||
|
}),
|
||||||
|
onMutate: (vars) => {
|
||||||
|
return optimisticRemoveOtherContact(queryClient, vars.profileId)
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
rollbackDispositionContext(queryClient, context)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée le contact NC puis marque le profil découvert comme accepté (retrait optimiste immédiat). */
|
||||||
|
export function useAddDiscoveredContact() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: {
|
||||||
|
bookId: string
|
||||||
|
profileId: string
|
||||||
|
contact: Partial<ApiContact>
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const created = await apiClient.post<ApiContact>(
|
||||||
|
`/contacts/discovery/profiles/${vars.profileId}/add-to-book`,
|
||||||
|
{ book_id: vars.bookId, contact: vars.contact },
|
||||||
|
)
|
||||||
|
return { created, contactUid: created.uid }
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 405)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const created = await apiClient.post<ApiContact | undefined>(
|
||||||
|
`/contacts/books/${vars.bookId}`,
|
||||||
|
vars.contact,
|
||||||
|
)
|
||||||
|
const contactUid = created?.uid ?? vars.contact.uid ?? ''
|
||||||
|
await apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
|
||||||
|
contact_uid: contactUid,
|
||||||
|
})
|
||||||
|
const merged: ApiContact = {
|
||||||
|
...(vars.contact as ApiContact),
|
||||||
|
...(created ?? {}),
|
||||||
|
uid: contactUid,
|
||||||
|
}
|
||||||
|
return { created: merged, contactUid }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: (vars) => {
|
||||||
|
return optimisticRemoveOtherContact(queryClient, vars.profileId)
|
||||||
|
},
|
||||||
|
onSuccess: (data, vars) => {
|
||||||
|
if (data.created.uid) {
|
||||||
|
appendContactToBookCache(queryClient, vars.bookId, data.created)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err, _vars, context) => {
|
||||||
|
rollbackDispositionContext(queryClient, context)
|
||||||
|
if (err instanceof OfflineError) {
|
||||||
|
enqueue({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'create_contact',
|
||||||
|
payload: { bookId: _vars.bookId, ..._vars.contact },
|
||||||
|
retries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectDiscoveredProfile() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
apiClient.post<void>(`/contacts/discovery/profiles/${profileId}/reject`),
|
||||||
|
onMutate: (profileId) => {
|
||||||
|
const dispositionContext = optimisticRemoveFromDispositionLists(queryClient, profileId)
|
||||||
|
void queryClient.cancelQueries({ queryKey: ['contact-discovery-ignored'] })
|
||||||
|
void queryClient.cancelQueries({ queryKey: ['contact-discovery-blocked'] })
|
||||||
|
return dispositionContext
|
||||||
|
},
|
||||||
|
onError: (_err, _profileId, context) => {
|
||||||
|
if (!context) return
|
||||||
|
if (context.previousIgnored) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
|
||||||
|
}
|
||||||
|
if (context.previousBlocked) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
|
||||||
|
}
|
||||||
|
if (context.previousCounts) {
|
||||||
|
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIgnoreDiscoveredProfile() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
apiClient.post<ApiDispositionEmails>(
|
||||||
|
`/contacts/discovery/profiles/${profileId}/ignore`,
|
||||||
|
),
|
||||||
|
onMutate: (profileId) => {
|
||||||
|
const context = optimisticRemoveOtherContact(queryClient, profileId)
|
||||||
|
prependIgnoredProfile(queryClient, context.removedGroup)
|
||||||
|
return context
|
||||||
|
},
|
||||||
|
onError: (_err, _profileId, context) => {
|
||||||
|
rollbackDispositionContext(queryClient, context)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlockDiscoveredProfile() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
apiClient.post<ApiDispositionEmails>(
|
||||||
|
`/contacts/discovery/profiles/${profileId}/block`,
|
||||||
|
),
|
||||||
|
onMutate: (profileId) => {
|
||||||
|
const context = optimisticRemoveOtherContact(queryClient, profileId)
|
||||||
|
prependBlockedProfile(queryClient, context.removedGroup)
|
||||||
|
return context
|
||||||
|
},
|
||||||
|
onError: (_err, _profileId, context) => {
|
||||||
|
rollbackDispositionContext(queryClient, context)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileEnrichResponse {
|
||||||
|
profile_id: string
|
||||||
|
enrichment_status: ApiDiscoveredProfile['enrichment_status']
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchGroupEnrichmentStatus(
|
||||||
|
groups: ApiDiscoveredProfileGroup[] | undefined,
|
||||||
|
profileId: string,
|
||||||
|
status: ApiDiscoveredProfile['enrichment_status'],
|
||||||
|
): ApiDiscoveredProfileGroup[] | undefined {
|
||||||
|
if (!groups) return groups
|
||||||
|
return groups.map((g) => {
|
||||||
|
const id = g.profile?.id ?? g.profile_ids[0]
|
||||||
|
if (id !== profileId) return g
|
||||||
|
const patchProfile = (p: ApiDiscoveredProfile): ApiDiscoveredProfile => ({
|
||||||
|
...p,
|
||||||
|
enrichment_status: status,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...g,
|
||||||
|
profile: g.profile ? patchProfile(g.profile) : g.profile,
|
||||||
|
profiles: g.profiles?.map(patchProfile),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnrichDiscoveredProfile() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
apiClient.post<ProfileEnrichResponse>(
|
||||||
|
`/contacts/discovery/profiles/${profileId}/enrich`,
|
||||||
|
),
|
||||||
|
onMutate: async (profileId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
|
||||||
|
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
|
||||||
|
OTHER_CONTACTS_QUERY_KEY,
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
groups:
|
||||||
|
patchGroupEnrichmentStatus(page.groups ?? [], profileId, 'enriching') ??
|
||||||
|
page.groups ??
|
||||||
|
[],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcceptEnrichmentSuggestion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (suggestionId: string) =>
|
||||||
|
apiClient.post<ApiEnrichmentSuggestion>(
|
||||||
|
`/contacts/discovery/suggestions/${suggestionId}/accept`,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectEnrichmentSuggestion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (suggestionId: string) =>
|
||||||
|
apiClient.post<void>(`/contacts/discovery/suggestions/${suggestionId}/reject`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLLMSettings(raw: ApiLLMSettings): ApiLLMSettings {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
default_provider_id: raw.default_provider_id ?? '',
|
||||||
|
providers: raw.providers ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLLMSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['llm-settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await apiClient.get<ApiLLMSettings>('/contacts/discovery/llm-settings')
|
||||||
|
return normalizeLLMSettings(data)
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLLMSettings() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (settings: ApiLLMSettings) =>
|
||||||
|
apiClient.put<ApiLLMSettings>('/contacts/discovery/llm-settings', settings),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['llm-settings'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchSettings(raw: ApiSearchSettings): ApiSearchSettings {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
default_provider_id: raw.default_provider_id ?? '',
|
||||||
|
providers: raw.providers ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['search-settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await apiClient.get<ApiSearchSettings>('/contacts/discovery/search-settings')
|
||||||
|
return normalizeSearchSettings(data)
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSearchSettings() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (settings: ApiSearchSettings) =>
|
||||||
|
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['search-settings'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoverLLMModels(baseUrl: string, apiKey: string) {
|
||||||
|
const trimmedBaseUrl = baseUrl.trim()
|
||||||
|
const trimmedApiKey = apiKey.trim()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['llm-models', trimmedBaseUrl, trimmedApiKey],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.post<ApiLLMModelsResponse>('/contacts/discovery/llm-models/discover', {
|
||||||
|
base_url: trimmedBaseUrl,
|
||||||
|
api_key: trimmedApiKey || undefined,
|
||||||
|
}),
|
||||||
|
enabled: trimmedBaseUrl.length > 0,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanProgressLabel(scan: ApiDiscoveryScan): string {
|
||||||
|
const phase = scanPhaseLabel(scan.phase)
|
||||||
|
if (scan.phase === 'enriching') {
|
||||||
|
const done = scan.profiles_found
|
||||||
|
const total = scan.profiles_total
|
||||||
|
if (total > 0) {
|
||||||
|
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} contacts`
|
||||||
|
}
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
if (scan.phase === 'building_profiles') {
|
||||||
|
const done = scan.profiles_found
|
||||||
|
const total = scan.profiles_total
|
||||||
|
if (total > 0) {
|
||||||
|
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} profils`
|
||||||
|
}
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
if (scan.phase === 'scanning_messages') {
|
||||||
|
if (scan.total_messages > 0) {
|
||||||
|
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} / ${scan.total_messages.toLocaleString('fr-FR')} messages`
|
||||||
|
}
|
||||||
|
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} messages`
|
||||||
|
}
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanPhaseLabel(phase: ApiDiscoveryScan['phase']): string {
|
||||||
|
switch (phase) {
|
||||||
|
case 'scanning_messages':
|
||||||
|
return 'Analyse des messages…'
|
||||||
|
case 'building_profiles':
|
||||||
|
return 'Construction des profils…'
|
||||||
|
case 'enriching':
|
||||||
|
return 'Enrichissement IA…'
|
||||||
|
case 'done':
|
||||||
|
return 'Terminé'
|
||||||
|
default:
|
||||||
|
return 'En attente…'
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,20 +3,17 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiClient, OfflineError } from '../client'
|
import { apiClient, OfflineError } from '../client'
|
||||||
import { enqueue } from '../offline-queue'
|
import { enqueue } from '../offline-queue'
|
||||||
|
import { fullContactToApiContact, buildContactUpdatePayload } from '../adapters'
|
||||||
|
import {
|
||||||
|
appendContactToBookCache,
|
||||||
|
replaceContactInBookCaches,
|
||||||
|
} from '../contact-book-cache'
|
||||||
|
import { invalidateContactListCache } from '../contact-list-cache'
|
||||||
|
import { contactApiPath } from '@/lib/contacts/contact-api-path'
|
||||||
|
import { mergeTwoContacts, mergeManyContacts } from '@/lib/contacts/merge-contacts'
|
||||||
|
import type { FullContact } from '@/lib/contacts/types'
|
||||||
import type { ApiContact } from '../types'
|
import type { ApiContact } from '../types'
|
||||||
|
|
||||||
function appendContactToBookCache(
|
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
|
||||||
bookId: string,
|
|
||||||
contact: ApiContact,
|
|
||||||
) {
|
|
||||||
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
|
|
||||||
const list = old ?? []
|
|
||||||
if (list.some((item) => item.uid === contact.uid)) return list
|
|
||||||
return [...list, contact]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateContact() {
|
export function useCreateContact() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@ -30,9 +27,15 @@ export function useCreateContact() {
|
|||||||
return vars.contact as ApiContact
|
return vars.contact as ApiContact
|
||||||
},
|
},
|
||||||
onSuccess: (data, vars) => {
|
onSuccess: (data, vars) => {
|
||||||
if (data?.uid) {
|
const contact = data?.uid ? data : (vars.contact as ApiContact)
|
||||||
appendContactToBookCache(queryClient, vars.bookId, data)
|
if (contact?.uid) {
|
||||||
|
appendContactToBookCache(queryClient, vars.bookId, {
|
||||||
|
...contact,
|
||||||
|
etag: contact.etag ?? data?.etag,
|
||||||
|
path: contact.path ?? data?.path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
invalidateContactListCache(vars.bookId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||||
},
|
},
|
||||||
onError: (err, vars) => {
|
onError: (err, vars) => {
|
||||||
@ -57,12 +60,28 @@ export function useUpdateContact() {
|
|||||||
path: string
|
path: string
|
||||||
contact: Partial<ApiContact>
|
contact: Partial<ApiContact>
|
||||||
etag?: string
|
etag?: string
|
||||||
}) => apiClient.put<ApiContact>(`/contacts/${vars.path}`, {
|
skipInvalidation?: boolean
|
||||||
...vars.contact,
|
}) => {
|
||||||
etag: vars.etag,
|
const ifMatch = vars.etag ?? vars.contact.etag
|
||||||
}),
|
const headers: Record<string, string> = {}
|
||||||
onSuccess: () => {
|
if (ifMatch) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
headers['If-Match'] = ifMatch
|
||||||
|
}
|
||||||
|
const { etag: _bodyEtag, ...contactBody } = vars.contact
|
||||||
|
const apiPath = vars.path.replace(/^\/+/, '')
|
||||||
|
return apiClient.put<{ etag?: string }>(`/contacts/${apiPath}`, contactBody, headers)
|
||||||
|
},
|
||||||
|
onSuccess: (data, vars) => {
|
||||||
|
const apiPath = vars.path.replace(/^\/+/, '')
|
||||||
|
const nextEtag = data?.etag ?? vars.etag
|
||||||
|
replaceContactInBookCaches(queryClient, apiPath, {
|
||||||
|
...vars.contact,
|
||||||
|
etag: nextEtag,
|
||||||
|
})
|
||||||
|
invalidateContactListCache()
|
||||||
|
if (!vars.skipInvalidation) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err, vars) => {
|
onError: (err, vars) => {
|
||||||
if (err instanceof OfflineError) {
|
if (err instanceof OfflineError) {
|
||||||
@ -117,6 +136,7 @@ export function useDeleteContact() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
|
invalidateContactListCache()
|
||||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -129,6 +149,84 @@ export function useMergeDuplicates() {
|
|||||||
mutationFn: async (vars: { bookId: string }) =>
|
mutationFn: async (vars: { bookId: string }) =>
|
||||||
apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`),
|
apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`),
|
||||||
onSuccess: (_data, vars) => {
|
onSuccess: (_data, vars) => {
|
||||||
|
invalidateContactListCache(vars.bookId)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMergeContactPair() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: {
|
||||||
|
bookId: string
|
||||||
|
contactA: FullContact
|
||||||
|
contactB: FullContact
|
||||||
|
}) => {
|
||||||
|
const { primary, secondary, merged } = mergeTwoContacts(vars.contactA, vars.contactB)
|
||||||
|
const primaryPath = contactApiPath(primary)
|
||||||
|
const secondaryPath = contactApiPath(secondary)
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (primary.etag) {
|
||||||
|
headers['If-Match'] = primary.etag
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.put(
|
||||||
|
`/contacts/${primaryPath}`,
|
||||||
|
fullContactToApiContact(merged),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (secondaryPath !== primaryPath) {
|
||||||
|
await apiClient.delete(`/contacts/${secondaryPath}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
invalidateContactListCache(vars.bookId)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMergeManyContacts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: {
|
||||||
|
bookId: string
|
||||||
|
contacts: FullContact[]
|
||||||
|
primaryId?: string
|
||||||
|
}) => {
|
||||||
|
const result = mergeManyContacts(vars.contacts, vars.primaryId)
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('At least 2 contacts are required to merge')
|
||||||
|
}
|
||||||
|
const { primary, secondaries, merged } = result
|
||||||
|
if (!primary.etag) {
|
||||||
|
throw new Error('Cannot merge: unknown contact version. Reload the list.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'If-Match': primary.etag,
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.put(
|
||||||
|
`/contacts/${contactApiPath(primary)}`,
|
||||||
|
fullContactToApiContact(merged),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const secondary of secondaries) {
|
||||||
|
const secondaryPath = contactApiPath(secondary)
|
||||||
|
if (secondaryPath !== contactApiPath(primary)) {
|
||||||
|
await apiClient.delete(`/contacts/${secondaryPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
invalidateContactListCache(vars.bookId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { rankApiContacts } from '@/lib/contacts/contact-match-score'
|
||||||
import { apiClient, OfflineError } from '../client'
|
import { apiClient, OfflineError } from '../client'
|
||||||
import type { ApiContact, ApiContactSyncResponse } from '../types'
|
import type { ApiContact, ApiContactSyncResponse } from '../types'
|
||||||
|
|
||||||
@ -11,6 +12,11 @@ type ApiContactBook = { id: string; name: string }
|
|||||||
|
|
||||||
type ApiContactsListResponse = {
|
type ApiContactsListResponse = {
|
||||||
contacts: ApiContact[]
|
contacts: ApiContact[]
|
||||||
|
pagination?: {
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook[] {
|
export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook[] {
|
||||||
@ -21,19 +27,40 @@ export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchContactByPath(path: string): Promise<ApiContact> {
|
||||||
|
const apiPath = path.replace(/^\/+/, '')
|
||||||
|
return apiClient.get<ApiContact>(`/contacts/${apiPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchContactsForBook(bookId: string): Promise<ApiContact[]> {
|
export async function fetchContactsForBook(bookId: string): Promise<ApiContact[]> {
|
||||||
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
|
const pageSize = 500
|
||||||
`/contacts/books/${bookId}`,
|
let page = 1
|
||||||
)
|
const all: ApiContact[] = []
|
||||||
return Array.isArray(res) ? res : (res.contacts ?? [])
|
|
||||||
|
while (page <= 100) {
|
||||||
|
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
|
||||||
|
`/contacts/books/${bookId}`,
|
||||||
|
{ page: String(page), page_size: String(pageSize) },
|
||||||
|
)
|
||||||
|
const batch = Array.isArray(res) ? res : (res.contacts ?? [])
|
||||||
|
all.push(...batch)
|
||||||
|
|
||||||
|
if (batch.length < pageSize) break
|
||||||
|
const total = !Array.isArray(res) ? res.pagination?.total : undefined
|
||||||
|
if (total != null && all.length >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDefaultContactBookId() {
|
export function useDefaultContactBookId() {
|
||||||
const { data: booksRaw } = useContactBooks()
|
const { data: booksRaw, isLoading, isError } = useContactBooks()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
|
if (isLoading || isError) return undefined
|
||||||
const books = normalizeContactBooksResponse(booksRaw)
|
const books = normalizeContactBooksResponse(booksRaw)
|
||||||
return books[0]?.id ?? FALLBACK_CONTACT_BOOK_ID
|
return books[0]?.id ?? FALLBACK_CONTACT_BOOK_ID
|
||||||
}, [booksRaw])
|
}, [booksRaw, isLoading, isError])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContacts(bookId?: string) {
|
export function useContacts(bookId?: string) {
|
||||||
@ -42,7 +69,7 @@ export function useContacts(bookId?: string) {
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['contacts', resolvedBookId],
|
queryKey: ['contacts', resolvedBookId],
|
||||||
queryFn: () => fetchContactsForBook(resolvedBookId),
|
queryFn: () => fetchContactsForBook(resolvedBookId!),
|
||||||
enabled: !!resolvedBookId,
|
enabled: !!resolvedBookId,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
@ -79,7 +106,12 @@ export function useSearchContacts(query: string) {
|
|||||||
queryKey: ['contacts-search', query],
|
queryKey: ['contacts-search', query],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
return await apiClient.get<ApiContact[]>('/contacts/search', { q: query })
|
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
|
||||||
|
'/contacts/search',
|
||||||
|
{ q: query },
|
||||||
|
)
|
||||||
|
const list = Array.isArray(res) ? res : (res.contacts ?? [])
|
||||||
|
return rankApiContacts(list, query)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof OfflineError) {
|
if (err instanceof OfflineError) {
|
||||||
const cached = queryClient.getQueriesData<ApiContact[]>({
|
const cached = queryClient.getQueriesData<ApiContact[]>({
|
||||||
@ -89,13 +121,7 @@ export function useSearchContacts(query: string) {
|
|||||||
for (const [, data] of cached) {
|
for (const [, data] of cached) {
|
||||||
if (data) allContacts.push(...data)
|
if (data) allContacts.push(...data)
|
||||||
}
|
}
|
||||||
const q = query.toLowerCase()
|
return rankApiContacts(allContacts, query)
|
||||||
return allContacts.filter(
|
|
||||||
(c) =>
|
|
||||||
c.full_name.toLowerCase().includes(q) ||
|
|
||||||
c.email?.toLowerCase().includes(q) ||
|
|
||||||
c.org?.toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|||||||
13
lib/api/hooks/use-improve-contact.ts
Normal file
13
lib/api/hooks/use-improve-contact.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { apiClient } from '../client'
|
||||||
|
import type { ApiEnrichedContactData } from '@/lib/contacts/discovery-types'
|
||||||
|
import type { ImproveContactPayload } from '@/lib/contacts/improve-contact'
|
||||||
|
|
||||||
|
export function useImproveContact() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ImproveContactPayload) =>
|
||||||
|
apiClient.post<ApiEnrichedContactData>('/contacts/improve', payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
46
lib/contact-avatar.test.ts
Normal file
46
lib/contact-avatar.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
avatarUrlToVCardPhotoLine,
|
||||||
|
gravatarUrl,
|
||||||
|
parseVCardPhoto,
|
||||||
|
} from './contact-avatar.ts'
|
||||||
|
|
||||||
|
describe('contact-avatar', () => {
|
||||||
|
it('builds stable Gravatar URL for normalized email', () => {
|
||||||
|
assert.equal(
|
||||||
|
gravatarUrl('Alice@Example.COM', 80),
|
||||||
|
'https://www.gravatar.com/avatar/7206d8c4dd027ffeae12fb6540cbed2ba?s=80&d=404',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses embedded vCard PHOTO as data URI', () => {
|
||||||
|
const photo = parseVCardPhoto('PHOTO;ENCODING=b;TYPE=PNG', 'aGVsbG8=')
|
||||||
|
assert.equal(photo, 'data:image/png;base64,aGVsbG8=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses vCard PHOTO URI', () => {
|
||||||
|
assert.equal(
|
||||||
|
parseVCardPhoto('PHOTO;VALUE=URI', 'https://cdn.example/face.jpg'),
|
||||||
|
'https://cdn.example/face.jpg',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes data URI to vCard PHOTO line', () => {
|
||||||
|
const line = avatarUrlToVCardPhotoLine(
|
||||||
|
'data:image/jpeg;base64,/9j/abc',
|
||||||
|
(v) => v,
|
||||||
|
)
|
||||||
|
assert.equal(line, 'PHOTO;ENCODING=b;TYPE=JPEG:/9j/abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips Gravatar URLs when exporting vCard', () => {
|
||||||
|
assert.equal(
|
||||||
|
avatarUrlToVCardPhotoLine(
|
||||||
|
'https://www.gravatar.com/avatar/abc?s=80&d=404',
|
||||||
|
(v) => v,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
230
lib/contact-avatar.ts
Normal file
230
lib/contact-avatar.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/** MD5 hex digest (Gravatar). Sync, no deps. */
|
||||||
|
function md5Hex(input: string): string {
|
||||||
|
const utf8 = unescape(encodeURIComponent(input))
|
||||||
|
|
||||||
|
function md5cycle(x: number[], k: number[]) {
|
||||||
|
let [a, b, c, d] = x
|
||||||
|
|
||||||
|
a = ff(a, b, c, d, k[0], 7, -680876936)
|
||||||
|
d = ff(d, a, b, c, k[1], 12, -389564586)
|
||||||
|
c = ff(c, d, a, b, k[2], 17, 606105819)
|
||||||
|
b = ff(b, c, d, a, k[3], 22, -1044525330)
|
||||||
|
a = ff(a, b, c, d, k[4], 7, -176418897)
|
||||||
|
d = ff(d, a, b, c, k[5], 12, 1200080426)
|
||||||
|
c = ff(c, d, a, b, k[6], 17, -1473231341)
|
||||||
|
b = ff(b, c, d, a, k[7], 22, -45705983)
|
||||||
|
a = ff(a, b, c, d, k[8], 7, 1770035416)
|
||||||
|
d = ff(d, a, b, c, k[9], 12, -1958414417)
|
||||||
|
c = ff(c, d, a, b, k[10], 17, -42063)
|
||||||
|
b = ff(b, c, d, a, k[11], 22, -1990404162)
|
||||||
|
a = ff(a, b, c, d, k[12], 7, 1804603682)
|
||||||
|
d = ff(d, a, b, c, k[13], 12, -40341101)
|
||||||
|
c = ff(c, d, a, b, k[14], 17, -1502002290)
|
||||||
|
b = ff(b, c, d, a, k[15], 22, 1236535329)
|
||||||
|
a = gg(a, b, c, d, k[1], 5, -165796510)
|
||||||
|
d = gg(d, a, b, c, k[6], 9, -1069501632)
|
||||||
|
c = gg(c, d, a, b, k[11], 14, 643717713)
|
||||||
|
b = gg(b, c, d, a, k[0], 20, -373897302)
|
||||||
|
a = gg(a, b, c, d, k[5], 5, -701558691)
|
||||||
|
d = gg(d, a, b, c, k[10], 9, 38016083)
|
||||||
|
c = gg(c, d, a, b, k[15], 14, -660478335)
|
||||||
|
b = gg(b, c, d, a, k[4], 20, -405537848)
|
||||||
|
a = gg(a, b, c, d, k[9], 5, 568446438)
|
||||||
|
d = gg(d, a, b, c, k[14], 9, -1019803690)
|
||||||
|
c = gg(c, d, a, b, k[3], 14, -187363961)
|
||||||
|
b = gg(b, c, d, a, k[8], 20, 1163531501)
|
||||||
|
a = gg(a, b, c, d, k[13], 5, -1444681467)
|
||||||
|
d = gg(d, a, b, c, k[2], 9, -51403784)
|
||||||
|
c = gg(c, d, a, b, k[7], 14, 1735328473)
|
||||||
|
b = gg(b, c, d, a, k[12], 20, -1926607734)
|
||||||
|
a = hh(a, b, c, d, k[5], 4, -378558)
|
||||||
|
d = hh(d, a, b, c, k[8], 11, -2022574463)
|
||||||
|
c = hh(c, d, a, b, k[11], 16, 1839030562)
|
||||||
|
b = hh(b, c, d, a, k[14], 23, -35309556)
|
||||||
|
a = hh(a, b, c, d, k[1], 4, -1530992060)
|
||||||
|
d = hh(d, a, b, c, k[4], 11, 1272893353)
|
||||||
|
c = hh(c, d, a, b, k[7], 16, -155497632)
|
||||||
|
b = hh(b, c, d, a, k[10], 23, -1094730640)
|
||||||
|
a = hh(a, b, c, d, k[13], 4, 681279174)
|
||||||
|
d = hh(d, a, b, c, k[0], 11, -358537222)
|
||||||
|
c = hh(c, d, a, b, k[3], 16, -722521979)
|
||||||
|
b = hh(b, c, d, a, k[6], 23, 76029189)
|
||||||
|
a = hh(a, b, c, d, k[9], 4, -640364487)
|
||||||
|
d = hh(d, a, b, c, k[12], 11, -421815835)
|
||||||
|
c = hh(c, d, a, b, k[15], 16, 530742520)
|
||||||
|
b = hh(b, c, d, a, k[2], 23, -995338651)
|
||||||
|
a = ii(a, b, c, d, k[0], 6, -198630844)
|
||||||
|
d = ii(d, a, b, c, k[7], 10, 1126891415)
|
||||||
|
c = ii(c, d, a, b, k[14], 15, -1416354905)
|
||||||
|
b = ii(b, c, d, a, k[5], 21, -57434055)
|
||||||
|
a = ii(a, b, c, d, k[12], 6, 1700485571)
|
||||||
|
d = ii(d, a, b, c, k[3], 10, -1894986606)
|
||||||
|
c = ii(c, d, a, b, k[10], 15, -1051523)
|
||||||
|
b = ii(b, c, d, a, k[1], 21, -2054922799)
|
||||||
|
a = ii(a, b, c, d, k[8], 6, 1873313359)
|
||||||
|
d = ii(d, a, b, c, k[15], 10, -30611744)
|
||||||
|
c = ii(c, d, a, b, k[6], 15, -1560198380)
|
||||||
|
b = ii(b, c, d, a, k[13], 21, 1309151649)
|
||||||
|
a = ii(a, b, c, d, k[4], 6, -145523070)
|
||||||
|
d = ii(d, a, b, c, k[11], 10, -1120210379)
|
||||||
|
c = ii(c, d, a, b, k[2], 15, 718787259)
|
||||||
|
b = ii(b, c, d, a, k[9], 21, -343485551)
|
||||||
|
|
||||||
|
x[0] = add32(a, x[0])
|
||||||
|
x[1] = add32(b, x[1])
|
||||||
|
x[2] = add32(c, x[2])
|
||||||
|
x[3] = add32(d, x[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||||
|
a = add32(add32(a, q), add32(x, t))
|
||||||
|
return add32((a << s) | (a >>> (32 - s)), b)
|
||||||
|
}
|
||||||
|
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & c) | (~b & d), a, b, x, s, t)
|
||||||
|
}
|
||||||
|
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn((b & d) | (c & ~d), a, b, x, s, t)
|
||||||
|
}
|
||||||
|
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(b ^ c ^ d, a, b, x, s, t)
|
||||||
|
}
|
||||||
|
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||||
|
return cmn(c ^ (b | ~d), a, b, x, s, t)
|
||||||
|
}
|
||||||
|
function add32(a: number, b: number) {
|
||||||
|
return (a + b) & 0xffffffff
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = utf8.length
|
||||||
|
const state = [1732584193, -271733879, -1732584194, 271733878]
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
for (; i + 64 <= n; i += 64) {
|
||||||
|
const block: number[] = []
|
||||||
|
for (let j = 0; j < 64; j += 4) {
|
||||||
|
block[j >> 2] =
|
||||||
|
utf8.charCodeAt(i + j) |
|
||||||
|
(utf8.charCodeAt(i + j + 1) << 8) |
|
||||||
|
(utf8.charCodeAt(i + j + 2) << 16) |
|
||||||
|
(utf8.charCodeAt(i + j + 3) << 24)
|
||||||
|
}
|
||||||
|
md5cycle(state, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = new Array<number>(16).fill(0)
|
||||||
|
for (let j = i; j < n; j++) {
|
||||||
|
tail[(j - i) >> 2] |= utf8.charCodeAt(j) << ((j - i) % 4) * 8
|
||||||
|
}
|
||||||
|
tail[(n - i) >> 2] |= 0x80 << ((n - i) % 4) * 8
|
||||||
|
if ((n - i) > 55) {
|
||||||
|
md5cycle(state, tail)
|
||||||
|
tail.fill(0)
|
||||||
|
}
|
||||||
|
tail[14] = n * 8
|
||||||
|
md5cycle(state, tail)
|
||||||
|
|
||||||
|
return state
|
||||||
|
.map((v) => {
|
||||||
|
let s = ''
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
s += ((v >> (j * 8)) & 0xff).toString(16).padStart(2, '0')
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gravatarUrl(email: string, size = 80): string | undefined {
|
||||||
|
const normalized = email.trim().toLowerCase()
|
||||||
|
if (!normalized || !normalized.includes('@')) return undefined
|
||||||
|
const hash = md5Hex(normalized)
|
||||||
|
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGravatarUrl(url: string | undefined): boolean {
|
||||||
|
return !!url && /gravatar\.com\/avatar\//i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primaryContactEmail(contact: {
|
||||||
|
emails?: { value: string }[]
|
||||||
|
}): string | undefined {
|
||||||
|
const email = contact.emails?.find((e) => e.value?.trim())?.value?.trim()
|
||||||
|
return email || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a vCard PHOTO property into a displayable URL / data URI. */
|
||||||
|
export function parseVCardPhoto(rawKey: string, value: string): string | undefined {
|
||||||
|
if (!rawKey.toUpperCase().startsWith('PHOTO') || !value.trim()) return undefined
|
||||||
|
|
||||||
|
const params = rawKey.split(';').slice(1).join(';').toUpperCase()
|
||||||
|
const trimmed = value.trim()
|
||||||
|
|
||||||
|
if (params.includes('VALUE=URI') || /^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/^uri:/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('data:')) return trimmed
|
||||||
|
|
||||||
|
let mime = 'image/jpeg'
|
||||||
|
const typeMatch = params.match(/(?:TYPE|MEDIATYPE)=([^;,]+)/)
|
||||||
|
if (typeMatch) {
|
||||||
|
const t = typeMatch[1].toLowerCase()
|
||||||
|
if (t === 'png') mime = 'image/png'
|
||||||
|
else if (t === 'gif') mime = 'image/gif'
|
||||||
|
else if (t === 'webp') mime = 'image/webp'
|
||||||
|
else if (t.includes('/')) mime = t
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data:${mime};base64,${trimmed.replace(/\s/g, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a vCard PHOTO line from avatarUrl (skip Gravatar — computed at display time). */
|
||||||
|
export function avatarUrlToVCardPhotoLine(
|
||||||
|
avatarUrl: string | undefined,
|
||||||
|
escapeValue: (v: string) => string,
|
||||||
|
): string | null {
|
||||||
|
const url = avatarUrl?.trim()
|
||||||
|
if (!url || isGravatarUrl(url)) return null
|
||||||
|
|
||||||
|
if (url.startsWith('data:')) {
|
||||||
|
const match = url.match(/^data:([^;]+);base64,([\s\S]+)$/)
|
||||||
|
if (!match) return null
|
||||||
|
const mime = match[1].toLowerCase()
|
||||||
|
const b64 = match[2].replace(/\s/g, '')
|
||||||
|
const type = mime.includes('png') ? 'PNG' : mime.includes('gif') ? 'GIF' : 'JPEG'
|
||||||
|
return `PHOTO;ENCODING=b;TYPE=${type}:${b64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
return `PHOTO;VALUE=uri:${escapeValue(url)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 512 * 1024
|
||||||
|
const ACCEPTED_IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
])
|
||||||
|
|
||||||
|
export async function readAvatarFromFile(file: File): Promise<string> {
|
||||||
|
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) {
|
||||||
|
throw new Error('Format non pris en charge. Utilisez JPEG, PNG, GIF ou WebP.')
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
throw new Error('Image trop volumineuse (max 512 Ko).')
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') resolve(reader.result)
|
||||||
|
else reject(new Error('Impossible de lire l\'image.'))
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Impossible de lire l\'image.'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -38,7 +38,18 @@ export const CONTACTS_ICON_BTN_CLASS =
|
|||||||
"text-muted-foreground hover:bg-accent hover:text-foreground"
|
"text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
|
||||||
export const CONTACTS_TABLE_HEADER_CLASS =
|
export const CONTACTS_TABLE_HEADER_CLASS =
|
||||||
"grid gap-2 border-b border-border py-2 text-xs font-medium text-muted-foreground"
|
"grid items-center gap-2 border-b border-border py-1.5 text-sm font-medium text-muted-foreground"
|
||||||
|
|
||||||
|
export const CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS =
|
||||||
|
"flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-muted/60 -m-0.5"
|
||||||
|
|
||||||
|
export const CONTACTS_TABLE_TOOLBAR_CLASS =
|
||||||
|
"mb-2 flex h-10 items-center justify-between gap-2"
|
||||||
|
|
||||||
|
/** Toolbar + en-têtes colonnes — sticky ensemble au scroll. */
|
||||||
|
export const CONTACTS_TABLE_STICKY_HEAD_CLASS = cn(
|
||||||
|
"sticky top-0 z-10 -mx-3 bg-app-canvas px-3 sm:-mx-6 sm:px-6",
|
||||||
|
)
|
||||||
|
|
||||||
export const CONTACTS_TABLE_ROW_CLASS = cn(
|
export const CONTACTS_TABLE_ROW_CLASS = cn(
|
||||||
"grid w-full cursor-pointer items-center gap-2 border-b border-border py-2.5 text-left text-sm",
|
"grid w-full cursor-pointer items-center gap-2 border-b border-border py-2.5 text-left text-sm",
|
||||||
@ -161,9 +172,62 @@ export const CONTACTS_PAGE_SECTION_TITLE_CLASS = "text-lg font-normal text-foreg
|
|||||||
|
|
||||||
export const CONTACTS_PAGE_HEADING_CLASS = cn("font-normal", CONTACTS_HEADING_TEXT)
|
export const CONTACTS_PAGE_HEADING_CLASS = cn("font-normal", CONTACTS_HEADING_TEXT)
|
||||||
|
|
||||||
export const CONTACTS_PAGE_CARD_CLASS = "rounded-xl border border-border p-5"
|
export const CONTACTS_PAGE_CARD_CLASS = cn(
|
||||||
|
"min-w-0 rounded-xl border border-mail-border bg-mail-surface p-5 shadow-sm",
|
||||||
|
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
|
||||||
|
)
|
||||||
|
|
||||||
export const CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS = "mt-3 border-t border-border pt-3"
|
/** Conteneur masonry discovery (2 colonnes flex en lg+) */
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS = "flex flex-col gap-5"
|
||||||
|
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_COLUMN_CLASS =
|
||||||
|
"flex min-w-0 flex-1 flex-col gap-5"
|
||||||
|
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS = cn(
|
||||||
|
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300 ease-out",
|
||||||
|
"motion-reduce:animate-none",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Sentinel chargement progressif — pleine largeur sous les colonnes */
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS = cn(
|
||||||
|
"flex w-full flex-col items-center justify-center py-6",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_CLASS = CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_CLASS = "min-w-0"
|
||||||
|
|
||||||
|
/** @deprecated Utiliser DiscoveryCardsMasonry */
|
||||||
|
export const CONTACTS_DISCOVERY_CARD_GRID_CLASS = CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS
|
||||||
|
|
||||||
|
export const CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS =
|
||||||
|
"mt-3 border-t border-mail-border pt-3"
|
||||||
|
|
||||||
|
/** Chips discovery (signatures, champs suggérés) — lisibles sur carte en dark mode */
|
||||||
|
export const CONTACTS_DISCOVERY_CHIP_CLASS = cn(
|
||||||
|
"inline-flex max-w-full items-center gap-0.5 rounded-full border px-2 py-0.5 text-xs",
|
||||||
|
"border-mail-list-chip-border bg-mail-list-chip-muted text-mail-list-chip-text",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Panneau secondaire dans une carte discovery (signature, détail champ) */
|
||||||
|
export const CONTACTS_DISCOVERY_INNER_PANEL_CLASS = cn(
|
||||||
|
"rounded-md border border-mail-border bg-mail-surface-muted/70 p-2",
|
||||||
|
"dark:border-mail-border dark:bg-mail-surface-muted",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ligne champ par champ (Ajouter des coordonnées) */
|
||||||
|
export const CONTACTS_DISCOVERY_FIELD_ROW_CLASS = cn(
|
||||||
|
"flex items-center justify-between gap-2 rounded-md border border-mail-border bg-mail-surface-muted/50 px-2 py-1.5",
|
||||||
|
"dark:bg-mail-surface-muted",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Cellule de groupe dans la grille dense des cartes discovery */
|
||||||
|
export const CONTACTS_DISCOVERY_GRID_CELL_CLASS = cn(
|
||||||
|
"min-w-0 rounded-lg border border-mail-border-subtle bg-mail-surface-muted/40 p-2",
|
||||||
|
"dark:border-mail-border-subtle dark:bg-mail-surface-muted/70",
|
||||||
|
)
|
||||||
|
|
||||||
export const CONTACTS_PAGE_LINK_BTN_CLASS =
|
export const CONTACTS_PAGE_LINK_BTN_CLASS =
|
||||||
"text-sm font-medium text-primary hover:text-primary/80"
|
"text-sm font-medium text-primary hover:text-primary/80"
|
||||||
@ -181,8 +245,10 @@ export const CONTACTS_PAGE_TAG_CLASS = cn(
|
|||||||
export const CONTACTS_PAGE_BANNER_CLASS =
|
export const CONTACTS_PAGE_BANNER_CLASS =
|
||||||
"mb-4 flex items-center justify-between rounded-lg bg-muted px-4 py-3"
|
"mb-4 flex items-center justify-between rounded-lg bg-muted px-4 py-3"
|
||||||
|
|
||||||
export const CONTACTS_PAGE_INFO_BANNER_CLASS =
|
export const CONTACTS_PAGE_INFO_BANNER_CLASS = cn(
|
||||||
"mb-6 flex items-start gap-4 rounded-xl bg-muted p-5"
|
"mb-6 flex items-start gap-4 rounded-xl border border-mail-border bg-mail-surface-muted p-5",
|
||||||
|
"dark:bg-mail-surface dark:border-mail-border",
|
||||||
|
)
|
||||||
|
|
||||||
export const CONTACTS_PAGE_INFO_BANNER_ICON_CLASS =
|
export const CONTACTS_PAGE_INFO_BANNER_ICON_CLASS =
|
||||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/15"
|
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/15"
|
||||||
|
|||||||
57
lib/contacts/bulk-edit-fields.ts
Normal file
57
lib/contacts/bulk-edit-fields.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { FullContact } from "./types"
|
||||||
|
|
||||||
|
export type ContactBulkEditField =
|
||||||
|
| "company"
|
||||||
|
| "department"
|
||||||
|
| "jobTitle"
|
||||||
|
| "website"
|
||||||
|
| "notes"
|
||||||
|
|
||||||
|
export const CONTACT_BULK_EDIT_FIELDS: {
|
||||||
|
id: ContactBulkEditField
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ id: "company", label: "Entreprise" },
|
||||||
|
{ id: "department", label: "Service" },
|
||||||
|
{ id: "jobTitle", label: "Poste" },
|
||||||
|
{ id: "website", label: "Site web" },
|
||||||
|
{ id: "notes", label: "Notes" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getContactBulkFieldValue(
|
||||||
|
contact: FullContact,
|
||||||
|
field: ContactBulkEditField,
|
||||||
|
): string {
|
||||||
|
const raw = contact[field]
|
||||||
|
return typeof raw === "string" ? raw.trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectBulkFieldSuggestions(
|
||||||
|
contacts: FullContact[],
|
||||||
|
field: ContactBulkEditField,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const contact of contacts) {
|
||||||
|
const value = getContactBulkFieldValue(contact, field)
|
||||||
|
if (!value) continue
|
||||||
|
const key = value.toLowerCase()
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(value)
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.localeCompare(b, "fr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBulkFieldValue(
|
||||||
|
contact: FullContact,
|
||||||
|
field: ContactBulkEditField,
|
||||||
|
value: string,
|
||||||
|
): FullContact {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
[field]: trimmed || undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
10
lib/contacts/contact-api-path.ts
Normal file
10
lib/contacts/contact-api-path.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { FullContact } from '@/lib/contacts/types'
|
||||||
|
|
||||||
|
/** Chemin API CardDAV pour PUT/DELETE (sans slash initial). */
|
||||||
|
export function contactApiPath(contact: Pick<FullContact, 'path' | 'id'>): string {
|
||||||
|
let path = (contact.path ?? contact.id).replace(/^\/+/, '')
|
||||||
|
if (path.startsWith('cloud/')) {
|
||||||
|
path = path.slice('cloud/'.length)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
127
lib/contacts/contact-match-score.ts
Normal file
127
lib/contacts/contact-match-score.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import type { ApiContact } from "@/lib/api/types"
|
||||||
|
import type { FullContact } from "./types"
|
||||||
|
import { fullContactDisplayName } from "./types"
|
||||||
|
|
||||||
|
export function normalizeContactSearchText(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryTokens(query: string): string[] {
|
||||||
|
return normalizeContactSearchText(query).split(/\s+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score 0-1. Strict substring match only (no fuzzy). Higher = closer match. */
|
||||||
|
export function fieldMatchScore(haystack: string, needle: string): number {
|
||||||
|
const h = normalizeContactSearchText(haystack)
|
||||||
|
const n = normalizeContactSearchText(needle)
|
||||||
|
if (!n || !h.includes(n)) return 0
|
||||||
|
if (h === n) return 1
|
||||||
|
|
||||||
|
if (h.startsWith(n)) {
|
||||||
|
return 0.95 + 0.05 * (n.length / h.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of h.split(/[\s@._+-]+/)) {
|
||||||
|
if (!word) continue
|
||||||
|
if (word.startsWith(n)) {
|
||||||
|
return 0.88 + 0.07 * (n.length / word.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = h.indexOf(n)
|
||||||
|
const positionBonus = 1 - (idx / Math.max(h.length, 1)) * 0.35
|
||||||
|
const lengthBonus = n.length / Math.max(h.length, 1)
|
||||||
|
return 0.42 + 0.28 * positionBonus + 0.22 * lengthBonus
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreAgainstFields(fields: string[], query: string): number {
|
||||||
|
const tokens = queryTokens(query)
|
||||||
|
if (tokens.length === 0) return 0
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
for (const token of tokens) {
|
||||||
|
let best = 0
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field) continue
|
||||||
|
best = Math.max(best, fieldMatchScore(field, token))
|
||||||
|
best = Math.max(best, fieldMatchScore(field, query))
|
||||||
|
}
|
||||||
|
if (best === 0) return 0
|
||||||
|
total += best
|
||||||
|
}
|
||||||
|
|
||||||
|
return total / tokens.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullContactSearchFields(contact: FullContact): string[] {
|
||||||
|
const fields = [
|
||||||
|
fullContactDisplayName(contact),
|
||||||
|
contact.firstName,
|
||||||
|
contact.lastName,
|
||||||
|
contact.middleName,
|
||||||
|
contact.company,
|
||||||
|
contact.department,
|
||||||
|
contact.jobTitle,
|
||||||
|
contact.website,
|
||||||
|
contact.notes,
|
||||||
|
...(contact.nicknames ?? []),
|
||||||
|
...contact.emails.map((e) => e.value),
|
||||||
|
...contact.phones.map((p) => p.value),
|
||||||
|
...(contact.addresses ?? []).flatMap((a) => [
|
||||||
|
a.street,
|
||||||
|
a.city,
|
||||||
|
a.region,
|
||||||
|
a.postalCode,
|
||||||
|
a.country,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
return fields.filter((value): value is string => Boolean(value?.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiContactSearchFields(contact: ApiContact): string[] {
|
||||||
|
return [contact.full_name, contact.email, contact.phone, contact.org].filter(
|
||||||
|
(value): value is string => Boolean(value?.trim())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreFullContact(contact: FullContact, query: string): number {
|
||||||
|
return scoreAgainstFields(fullContactSearchFields(contact), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreApiContact(contact: ApiContact, query: string): number {
|
||||||
|
return scoreAgainstFields(apiContactSearchFields(contact), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankFullContacts(contacts: FullContact[], query: string): FullContact[] {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return contacts
|
||||||
|
|
||||||
|
return contacts
|
||||||
|
.map((contact) => ({ contact, score: scoreFullContact(contact, trimmed) }))
|
||||||
|
.filter((entry) => entry.score > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score
|
||||||
|
const nameA = fullContactDisplayName(a.contact) || a.contact.emails[0]?.value || ""
|
||||||
|
const nameB = fullContactDisplayName(b.contact) || b.contact.emails[0]?.value || ""
|
||||||
|
return nameA.localeCompare(nameB, "fr")
|
||||||
|
})
|
||||||
|
.map((entry) => entry.contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankApiContacts(contacts: ApiContact[], query: string): ApiContact[] {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return contacts
|
||||||
|
|
||||||
|
return contacts
|
||||||
|
.map((contact) => ({ contact, score: scoreApiContact(contact, trimmed) }))
|
||||||
|
.filter((entry) => entry.score > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score
|
||||||
|
return a.contact.full_name.localeCompare(b.contact.full_name, "fr")
|
||||||
|
})
|
||||||
|
.map((entry) => entry.contact)
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { create } from "zustand"
|
|||||||
import { persist } from "zustand/middleware"
|
import { persist } from "zustand/middleware"
|
||||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||||
import type { FullContact } from "./types"
|
import type { FullContact } from "./types"
|
||||||
|
import { mergePairKey } from "./duplicate-detection"
|
||||||
|
|
||||||
type ContactsView = "list" | "view" | "create" | "edit"
|
type ContactsView = "list" | "view" | "create" | "edit"
|
||||||
|
|
||||||
@ -149,7 +150,7 @@ export const useContactsStore = create<ContactsStore>()(
|
|||||||
|
|
||||||
ignoreMergePair: (idA, idB) =>
|
ignoreMergePair: (idA, idB) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const key = [idA, idB].sort().join("::")
|
const key = mergePairKey(idA, idB)
|
||||||
if (s.ignoredMergePairs.includes(key)) return s
|
if (s.ignoredMergePairs.includes(key)) return s
|
||||||
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
|
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
|
||||||
}),
|
}),
|
||||||
|
|||||||
224
lib/contacts/discovery-draft.ts
Normal file
224
lib/contacts/discovery-draft.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from './discovery-types'
|
||||||
|
import type { FullContact } from './types'
|
||||||
|
import { FIELD_LABELS, discoveredProfileToFullContact } from './discovery-utils'
|
||||||
|
import type { ContactAddress } from './types'
|
||||||
|
|
||||||
|
export interface DraftField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
fieldKey: string
|
||||||
|
value: string
|
||||||
|
removed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldId(fieldKey: string, value: string) {
|
||||||
|
return `${fieldKey}::${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clé de déduplication par valeur normalisée (indépendante du libellé / source). */
|
||||||
|
function normalizedValueKey(fieldKey: string, value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
if (fieldKey === 'emails') {
|
||||||
|
return `emails:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
if (fieldKey === 'phones') {
|
||||||
|
const digits = v.replace(/\D/g, '')
|
||||||
|
return digits.length >= 6 ? `phones:${digits}` : `phones:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
if (fieldKey === 'addresses') {
|
||||||
|
return `addresses:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
return `${fieldKey}:${v.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushDraftField(
|
||||||
|
out: DraftField[],
|
||||||
|
seen: Set<string>,
|
||||||
|
fieldKey: string,
|
||||||
|
value: string,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const v = value.trim()
|
||||||
|
if (!v) return
|
||||||
|
const dedupeKey = normalizedValueKey(fieldKey, v)
|
||||||
|
if (seen.has(dedupeKey)) return
|
||||||
|
seen.add(dedupeKey)
|
||||||
|
out.push({
|
||||||
|
id: fieldId(fieldKey, fieldKey === 'emails' ? v.toLowerCase() : v),
|
||||||
|
label,
|
||||||
|
fieldKey,
|
||||||
|
value: v,
|
||||||
|
removed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushScalar(
|
||||||
|
out: DraftField[],
|
||||||
|
fieldKey: string,
|
||||||
|
value: string | undefined,
|
||||||
|
seen: Set<string>,
|
||||||
|
) {
|
||||||
|
const v = value?.trim()
|
||||||
|
if (!v) return
|
||||||
|
pushDraftField(out, seen, fieldKey, v, FIELD_LABELS[fieldKey] ?? fieldKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLabeled(
|
||||||
|
out: DraftField[],
|
||||||
|
fieldKey: string,
|
||||||
|
items: { value: string; label?: string }[] | undefined,
|
||||||
|
seen: Set<string>,
|
||||||
|
) {
|
||||||
|
for (const item of items ?? []) {
|
||||||
|
const v = item.value?.trim()
|
||||||
|
if (!v) continue
|
||||||
|
const suffix = item.label ? ` (${item.label})` : ''
|
||||||
|
pushDraftField(
|
||||||
|
out,
|
||||||
|
seen,
|
||||||
|
fieldKey,
|
||||||
|
v,
|
||||||
|
`${FIELD_LABELS[fieldKey] ?? fieldKey}${suffix}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraftFields(group: ApiDiscoveredProfileGroup): DraftField[] {
|
||||||
|
const profile = group.profile ?? group.profiles?.[0]
|
||||||
|
if (!profile) return []
|
||||||
|
|
||||||
|
const data = profile.enriched_data
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: DraftField[] = []
|
||||||
|
|
||||||
|
pushScalar(out, 'first_name', data?.first_name, seen)
|
||||||
|
pushScalar(out, 'last_name', data?.last_name, seen)
|
||||||
|
pushScalar(out, 'company', data?.company, seen)
|
||||||
|
pushScalar(out, 'department', data?.department, seen)
|
||||||
|
pushScalar(out, 'job_title', data?.job_title, seen)
|
||||||
|
pushScalar(out, 'website', data?.website, seen)
|
||||||
|
pushLabeled(out, 'social_profiles', data?.social_profiles, seen)
|
||||||
|
pushScalar(out, 'notes', data?.notes, seen)
|
||||||
|
pushLabeled(out, 'emails', data?.emails, seen)
|
||||||
|
pushLabeled(out, 'phones', data?.phones, seen)
|
||||||
|
|
||||||
|
for (const addr of data?.addresses ?? []) {
|
||||||
|
const parts = [addr.street, addr.city, addr.region, addr.postal_code, addr.country]
|
||||||
|
.map((p) => p?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (parts.length === 0) continue
|
||||||
|
const v = parts.join(', ')
|
||||||
|
pushDraftField(
|
||||||
|
out,
|
||||||
|
seen,
|
||||||
|
'addresses',
|
||||||
|
v,
|
||||||
|
addr.label ? `${FIELD_LABELS.addresses} (${addr.label})` : FIELD_LABELS.addresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of group.profiles ?? [profile]) {
|
||||||
|
for (const e of p.all_emails ?? []) {
|
||||||
|
const v = e.email?.trim()
|
||||||
|
if (!v) continue
|
||||||
|
pushDraftField(out, seen, 'emails', v, FIELD_LABELS.emails)
|
||||||
|
}
|
||||||
|
const primary = p.primary_email?.trim()
|
||||||
|
if (primary) {
|
||||||
|
pushDraftField(out, seen, 'emails', primary, FIELD_LABELS.emails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFieldContactLabel(field: DraftField): string {
|
||||||
|
const base = FIELD_LABELS[field.fieldKey] ?? field.fieldKey
|
||||||
|
if (field.label.startsWith(base)) {
|
||||||
|
const suffix = field.label.slice(base.length).trim()
|
||||||
|
if (suffix.startsWith('(') && suffix.endsWith(')')) {
|
||||||
|
return suffix.slice(1, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Autre'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAddressDraftValue(value: string, label: string): ContactAddress {
|
||||||
|
const parts = value
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (parts.length >= 5) {
|
||||||
|
return {
|
||||||
|
street: parts[0],
|
||||||
|
city: parts[1],
|
||||||
|
region: parts[2],
|
||||||
|
postalCode: parts[3],
|
||||||
|
country: parts.slice(4).join(', '),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { street: value, label }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDraftToContact(
|
||||||
|
profile: ApiDiscoveredProfile | undefined,
|
||||||
|
fields: DraftField[],
|
||||||
|
): FullContact {
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error('profile is required to build contact')
|
||||||
|
}
|
||||||
|
const base = discoveredProfileToFullContact(profile)
|
||||||
|
const active = fields.filter((f) => !f.removed)
|
||||||
|
|
||||||
|
const scalar = (key: string) => active.find((f) => f.fieldKey === key)?.value
|
||||||
|
|
||||||
|
const firstName = scalar('first_name')
|
||||||
|
const lastName = scalar('last_name')
|
||||||
|
if (firstName) base.firstName = firstName
|
||||||
|
if (lastName) base.lastName = lastName
|
||||||
|
if (scalar('company')) base.company = scalar('company')
|
||||||
|
if (scalar('department')) base.department = scalar('department')
|
||||||
|
if (scalar('job_title')) base.jobTitle = scalar('job_title')
|
||||||
|
if (scalar('website')) base.website = scalar('website')
|
||||||
|
if (scalar('notes')) base.notes = scalar('notes')
|
||||||
|
|
||||||
|
const hasSocialFields = fields.some((f) => f.fieldKey === 'social_profiles')
|
||||||
|
const socialProfiles = active
|
||||||
|
.filter((f) => f.fieldKey === 'social_profiles')
|
||||||
|
.map((f) => ({
|
||||||
|
value: f.value,
|
||||||
|
label: draftFieldContactLabel(f),
|
||||||
|
}))
|
||||||
|
if (hasSocialFields) {
|
||||||
|
base.socialProfiles = socialProfiles.length > 0 ? socialProfiles : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEmailFields = fields.some((f) => f.fieldKey === 'emails')
|
||||||
|
const emails = active
|
||||||
|
.filter((f) => f.fieldKey === 'emails')
|
||||||
|
.map((f) => ({
|
||||||
|
value: f.value,
|
||||||
|
label: draftFieldContactLabel(f),
|
||||||
|
}))
|
||||||
|
if (hasEmailFields) base.emails = emails
|
||||||
|
|
||||||
|
const hasPhoneFields = fields.some((f) => f.fieldKey === 'phones')
|
||||||
|
const phones = active
|
||||||
|
.filter((f) => f.fieldKey === 'phones')
|
||||||
|
.map((f) => ({
|
||||||
|
value: f.value,
|
||||||
|
label: draftFieldContactLabel(f),
|
||||||
|
}))
|
||||||
|
if (hasPhoneFields) base.phones = phones
|
||||||
|
|
||||||
|
const hasAddressFields = fields.some((f) => f.fieldKey === 'addresses')
|
||||||
|
const addresses = active
|
||||||
|
.filter((f) => f.fieldKey === 'addresses')
|
||||||
|
.map((f) => parseAddressDraftValue(f.value, draftFieldContactLabel(f)))
|
||||||
|
if (hasAddressFields) {
|
||||||
|
base.addresses = addresses.length > 0 ? addresses : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
164
lib/contacts/discovery-grouping.ts
Normal file
164
lib/contacts/discovery-grouping.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import type {
|
||||||
|
ApiDiscoveredProfile,
|
||||||
|
ApiDiscoveredProfileGroup,
|
||||||
|
ApiDiscoveryOtherPage,
|
||||||
|
} from './discovery-types'
|
||||||
|
import { profileDisplayName } from './discovery-utils'
|
||||||
|
|
||||||
|
export function profileToDiscoveryGroup(
|
||||||
|
profile: ApiDiscoveredProfile,
|
||||||
|
): ApiDiscoveredProfileGroup {
|
||||||
|
return {
|
||||||
|
group_key: profile.id,
|
||||||
|
profile_ids: [profile.id],
|
||||||
|
display_name: profileDisplayName(profile),
|
||||||
|
primary_email: profile.primary_email,
|
||||||
|
message_count: profile.message_count,
|
||||||
|
profile,
|
||||||
|
profiles: [profile],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscoveredProfile(value: unknown): value is ApiDiscoveredProfile {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
return typeof row.id === 'string' && typeof row.primary_email === 'string' && !('profile' in row)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDiscoveryGroup(
|
||||||
|
group: ApiDiscoveredProfileGroup,
|
||||||
|
): ApiDiscoveredProfileGroup | null {
|
||||||
|
const profile = group.profile ?? group.profiles?.[0]
|
||||||
|
if (!profile?.id) return null
|
||||||
|
|
||||||
|
const profileIds =
|
||||||
|
group.profile_ids?.length > 0 ? group.profile_ids : [profile.id]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
group_key: group.group_key || profileIds[0] || profile.id,
|
||||||
|
profile_ids: profileIds,
|
||||||
|
profile,
|
||||||
|
profiles: group.profiles?.length ? group.profiles : [profile],
|
||||||
|
display_name: group.display_name || profileDisplayName(profile),
|
||||||
|
primary_email: group.primary_email || profile.primary_email,
|
||||||
|
message_count: group.message_count ?? profile.message_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryOtherPageResult {
|
||||||
|
groups: ApiDiscoveredProfileGroup[]
|
||||||
|
total: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDiscoveryOtherPage(
|
||||||
|
res: ApiDiscoveryOtherPage | {
|
||||||
|
groups?: ApiDiscoveredProfileGroup[]
|
||||||
|
profiles?: ApiDiscoveredProfile[]
|
||||||
|
total?: number
|
||||||
|
has_more?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
},
|
||||||
|
options?: { offset?: number; pageSize?: number },
|
||||||
|
): DiscoveryOtherPageResult {
|
||||||
|
const offset = options?.offset ?? 0
|
||||||
|
const pageSize = options?.pageSize ?? 20
|
||||||
|
const allGroups = parseDiscoveryOtherResponse(res)
|
||||||
|
const hasServerHasMore = 'has_more' in res && res.has_more != null
|
||||||
|
const hasServerTotal = typeof res.total === 'number'
|
||||||
|
const serverLimit = typeof res.limit === 'number' ? res.limit : undefined
|
||||||
|
|
||||||
|
// Réponse legacy : tous les groupes en une fois sans pagination
|
||||||
|
const looksUnpaginated =
|
||||||
|
!hasServerHasMore &&
|
||||||
|
!hasServerTotal &&
|
||||||
|
allGroups.length > pageSize &&
|
||||||
|
(serverLimit == null || serverLimit >= allGroups.length)
|
||||||
|
|
||||||
|
if (looksUnpaginated) {
|
||||||
|
const groups = allGroups.slice(offset, offset + pageSize)
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
total: allGroups.length,
|
||||||
|
hasMore: offset + pageSize < allGroups.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = allGroups
|
||||||
|
const total = hasServerTotal ? res.total! : groups.length + offset
|
||||||
|
const hasMore = hasServerHasMore
|
||||||
|
? Boolean(res.has_more)
|
||||||
|
: hasServerTotal
|
||||||
|
? offset + groups.length < res.total!
|
||||||
|
: groups.length >= pageSize
|
||||||
|
|
||||||
|
return { groups, total, hasMore }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDiscoveryOtherResponse(res: {
|
||||||
|
groups?: ApiDiscoveredProfileGroup[]
|
||||||
|
profiles?: ApiDiscoveredProfile[]
|
||||||
|
}): ApiDiscoveredProfileGroup[] {
|
||||||
|
if (res.groups?.length) {
|
||||||
|
const normalized: ApiDiscoveredProfileGroup[] = []
|
||||||
|
for (const group of res.groups) {
|
||||||
|
const row = normalizeDiscoveryGroup(group)
|
||||||
|
if (row) normalized.push(row)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.profiles?.length) {
|
||||||
|
return res.profiles.map(profileToDiscoveryGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable React key — prefer person group id when several profiles are merged. */
|
||||||
|
export function discoveryGroupReactKey(group: ApiDiscoveredProfileGroup): string {
|
||||||
|
const profileIds = (group.profile_ids ?? []).filter(Boolean)
|
||||||
|
const profileId = group.profile?.id ?? profileIds[0]
|
||||||
|
const groupKey = group.group_key?.trim()
|
||||||
|
|
||||||
|
if (groupKey && profileIds.length > 1) {
|
||||||
|
return groupKey
|
||||||
|
}
|
||||||
|
if (groupKey && profileId && groupKey !== profileId) {
|
||||||
|
return groupKey
|
||||||
|
}
|
||||||
|
return profileId ?? groupKey ?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove duplicate groups (infinite scroll / refetch overlap). */
|
||||||
|
export function dedupeDiscoveryGroups(
|
||||||
|
groups: ApiDiscoveredProfileGroup[],
|
||||||
|
): ApiDiscoveredProfileGroup[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: ApiDiscoveredProfileGroup[] = []
|
||||||
|
for (const group of groups) {
|
||||||
|
const normalized = normalizeDiscoveryGroup(group)
|
||||||
|
if (!normalized) continue
|
||||||
|
const key = discoveryGroupReactKey(normalized)
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(normalized)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce cached query rows that may still be flat profiles. */
|
||||||
|
export function coerceDiscoveryGroups(data: unknown): ApiDiscoveredProfileGroup[] {
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return []
|
||||||
|
if (isDiscoveredProfile(data[0])) {
|
||||||
|
return (data as ApiDiscoveredProfile[]).map(profileToDiscoveryGroup)
|
||||||
|
}
|
||||||
|
const out: ApiDiscoveredProfileGroup[] = []
|
||||||
|
for (const row of data as ApiDiscoveredProfileGroup[]) {
|
||||||
|
const normalized = normalizeDiscoveryGroup(row)
|
||||||
|
if (normalized) out.push(normalized)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
155
lib/contacts/discovery-types.ts
Normal file
155
lib/contacts/discovery-types.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
export interface ApiLLMProvider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
api_key?: string
|
||||||
|
default_model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiLLMSettings {
|
||||||
|
default_provider_id: string
|
||||||
|
providers: ApiLLMProvider[]
|
||||||
|
contact_discovery_model?: string
|
||||||
|
contact_discovery_provider_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiLLMModelsResponse {
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiSearchProviderType = 'brave'
|
||||||
|
|
||||||
|
export interface ApiSearchProvider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: ApiSearchProviderType
|
||||||
|
api_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSearchSettings {
|
||||||
|
default_provider_id: string
|
||||||
|
providers: ApiSearchProvider[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveredEmail {
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
roles?: string[]
|
||||||
|
message_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveredSignature {
|
||||||
|
id: string
|
||||||
|
message_id?: string
|
||||||
|
signature_text: string
|
||||||
|
message_date: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEnrichedContactData {
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
company?: string
|
||||||
|
department?: string
|
||||||
|
job_title?: string
|
||||||
|
emails?: { value: string; label: string }[]
|
||||||
|
phones?: { value: string; label: string }[]
|
||||||
|
addresses?: {
|
||||||
|
street?: string
|
||||||
|
city?: string
|
||||||
|
region?: string
|
||||||
|
postal_code?: string
|
||||||
|
country?: string
|
||||||
|
label: string
|
||||||
|
}[]
|
||||||
|
website?: string
|
||||||
|
social_profiles?: { value: string; label: string }[]
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveredProfile {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
primary_email: string
|
||||||
|
all_emails: ApiDiscoveredEmail[]
|
||||||
|
message_count: number
|
||||||
|
sent_count: number
|
||||||
|
received_count: number
|
||||||
|
spam_count: number
|
||||||
|
forwarded_count: number
|
||||||
|
is_mailing_list: boolean
|
||||||
|
is_disposable: boolean
|
||||||
|
is_spam_heavy: boolean
|
||||||
|
classification_reason?: string
|
||||||
|
linked_contact_uid?: string
|
||||||
|
enrichment_status: 'pending' | 'enriching' | 'enriched' | 'skipped' | 'failed'
|
||||||
|
enriched_data?: ApiEnrichedContactData
|
||||||
|
status: 'suggested' | 'accepted' | 'rejected' | 'ignored' | 'blocked'
|
||||||
|
signatures?: ApiDiscoveredSignature[]
|
||||||
|
detected_in_accounts?: ApiAccountDetection[]
|
||||||
|
last_message_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEnrichmentSuggestion {
|
||||||
|
id: string
|
||||||
|
profile_id?: string
|
||||||
|
target_contact_uid?: string
|
||||||
|
suggestion_type: 'new_contact' | 'enrich_contact'
|
||||||
|
field_path: string
|
||||||
|
suggested_value: string
|
||||||
|
suggested_label: string
|
||||||
|
confidence: number
|
||||||
|
status: 'pending' | 'accepted' | 'rejected'
|
||||||
|
profile?: ApiDiscoveredProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiAccountDetection {
|
||||||
|
account_id: string
|
||||||
|
account_email: string
|
||||||
|
account_name: string
|
||||||
|
message_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveryScan {
|
||||||
|
id: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
phase: 'pending' | 'scanning_messages' | 'building_profiles' | 'enriching' | 'done'
|
||||||
|
messages_scanned: number
|
||||||
|
total_messages: number
|
||||||
|
profiles_found: number
|
||||||
|
profiles_total: number
|
||||||
|
progress_percent: number
|
||||||
|
error_message?: string
|
||||||
|
started_at: string
|
||||||
|
updated_at: string
|
||||||
|
completed_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveryOtherPage {
|
||||||
|
groups: ApiDiscoveredProfileGroup[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveredProfileGroup {
|
||||||
|
group_key: string
|
||||||
|
profile_ids: string[]
|
||||||
|
display_name: string
|
||||||
|
primary_email: string
|
||||||
|
message_count: number
|
||||||
|
profile: ApiDiscoveredProfile
|
||||||
|
profiles?: ApiDiscoveredProfile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDiscoveryCounts {
|
||||||
|
other_contacts: number
|
||||||
|
suggestions: number
|
||||||
|
ignored: number
|
||||||
|
blocked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDispositionEmails {
|
||||||
|
emails: string[]
|
||||||
|
}
|
||||||
241
lib/contacts/discovery-utils.ts
Normal file
241
lib/contacts/discovery-utils.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import type {
|
||||||
|
ApiDiscoveredProfile,
|
||||||
|
ApiEnrichedContactData,
|
||||||
|
ApiEnrichmentSuggestion,
|
||||||
|
} from './discovery-types'
|
||||||
|
import type { FullContact } from './types'
|
||||||
|
|
||||||
|
const LABEL_MAP: Record<string, string> = {
|
||||||
|
work: 'Travail',
|
||||||
|
home: 'Domicile',
|
||||||
|
mobile: 'Mobile',
|
||||||
|
other: 'Autre',
|
||||||
|
linkedin: 'LinkedIn',
|
||||||
|
twitter: 'X / Twitter',
|
||||||
|
facebook: 'Facebook',
|
||||||
|
instagram: 'Instagram',
|
||||||
|
github: 'GitHub',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLabel(label?: string): string {
|
||||||
|
if (!label) return 'Autre'
|
||||||
|
return LABEL_MAP[label.toLowerCase()] ?? label
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoveredProfileToFullContact(profile: ApiDiscoveredProfile): FullContact {
|
||||||
|
const data: ApiEnrichedContactData | undefined = profile.enriched_data
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const emails = data?.emails?.length
|
||||||
|
? data.emails.map((e) => ({ value: e.value, label: mapLabel(e.label) }))
|
||||||
|
: [{ value: profile.primary_email, label: 'Autre' }]
|
||||||
|
|
||||||
|
const phones = (data?.phones ?? []).map((p) => ({
|
||||||
|
value: p.value,
|
||||||
|
label: mapLabel(p.label),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const addresses = (data?.addresses ?? []).map((a) => ({
|
||||||
|
street: a.street,
|
||||||
|
city: a.city,
|
||||||
|
region: a.region,
|
||||||
|
postalCode: a.postal_code,
|
||||||
|
country: a.country,
|
||||||
|
label: mapLabel(a.label),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const socialProfiles = (data?.social_profiles ?? []).map((p) => ({
|
||||||
|
value: p.value,
|
||||||
|
label: mapLabel(p.label),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
firstName: data?.first_name ?? profile.display_name.split(' ')[0] ?? '',
|
||||||
|
lastName: data?.last_name ?? profile.display_name.split(' ').slice(1).join(' ') ?? '',
|
||||||
|
company: data?.company,
|
||||||
|
department: data?.department,
|
||||||
|
jobTitle: data?.job_title,
|
||||||
|
website: data?.website,
|
||||||
|
socialProfiles: socialProfiles.length > 0 ? socialProfiles : undefined,
|
||||||
|
emails,
|
||||||
|
phones,
|
||||||
|
addresses: addresses.length > 0 ? addresses : undefined,
|
||||||
|
notes: data?.notes,
|
||||||
|
interactionCount: profile.message_count,
|
||||||
|
isOtherContact: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailLocalPart(email: string): string {
|
||||||
|
const at = email.lastIndexOf('@')
|
||||||
|
if (at <= 0) return email.trim().toLowerCase()
|
||||||
|
return email.slice(0, at).trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulDisplayName(name: string | undefined, email: string): boolean {
|
||||||
|
const trimmed = name?.trim()
|
||||||
|
if (!trimmed || trimmed.includes('@')) return false
|
||||||
|
const local = emailLocalPart(email)
|
||||||
|
if (!local) return true
|
||||||
|
return trimmed.toLowerCase() !== local
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichedDataHasValueBeyondEmail(
|
||||||
|
data: ApiEnrichedContactData | undefined | null,
|
||||||
|
): boolean {
|
||||||
|
if (!data) return false
|
||||||
|
if (
|
||||||
|
data.first_name?.trim() ||
|
||||||
|
data.last_name?.trim() ||
|
||||||
|
data.company?.trim() ||
|
||||||
|
data.department?.trim() ||
|
||||||
|
data.job_title?.trim() ||
|
||||||
|
data.website?.trim() ||
|
||||||
|
data.notes?.trim()
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (data.social_profiles?.some((p) => p.value?.trim())) return true
|
||||||
|
if (data.phones?.some((p) => p.value?.trim())) return true
|
||||||
|
return (
|
||||||
|
data.addresses?.some(
|
||||||
|
(a) =>
|
||||||
|
a.street?.trim() ||
|
||||||
|
a.city?.trim() ||
|
||||||
|
a.region?.trim() ||
|
||||||
|
a.postal_code?.trim() ||
|
||||||
|
a.country?.trim(),
|
||||||
|
) ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when profile has more than a bare email (signature, name, enriched fields, etc.). */
|
||||||
|
export function profileHasValueBeyondEmail(profile: ApiDiscoveredProfile): boolean {
|
||||||
|
if ((profile.signatures?.length ?? 0) > 0) return true
|
||||||
|
if (enrichedDataHasValueBeyondEmail(profile.enriched_data)) return true
|
||||||
|
if (hasMeaningfulDisplayName(profile.display_name, profile.primary_email)) return true
|
||||||
|
for (const e of profile.all_emails ?? []) {
|
||||||
|
if (hasMeaningfulDisplayName(e.display_name, e.email)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuggestableDiscoveryGroup(group: {
|
||||||
|
profile?: ApiDiscoveredProfile
|
||||||
|
profiles?: ApiDiscoveredProfile[]
|
||||||
|
}): boolean {
|
||||||
|
const profiles = group.profiles?.length
|
||||||
|
? group.profiles
|
||||||
|
: group.profile
|
||||||
|
? [group.profile]
|
||||||
|
: []
|
||||||
|
if (profiles.some((p) => profileHasNoReplyEmail(p))) return false
|
||||||
|
return profiles.some((p) => profileHasValueBeyondEmail(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileDisplayName(profile: ApiDiscoveredProfile): string {
|
||||||
|
const data = profile.enriched_data
|
||||||
|
if (data?.first_name || data?.last_name) {
|
||||||
|
return `${data.first_name ?? ''} ${data.last_name ?? ''}`.trim()
|
||||||
|
}
|
||||||
|
return profile.display_name || profile.primary_email
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(s: string): string {
|
||||||
|
return s.replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function phonesMatch(a: string, b: string): boolean {
|
||||||
|
const na = normalizePhone(a)
|
||||||
|
const nb = normalizePhone(b)
|
||||||
|
if (!na || !nb) return false
|
||||||
|
if (na.length >= 9 && nb.length >= 9) {
|
||||||
|
return na.slice(-9) === nb.slice(-9)
|
||||||
|
}
|
||||||
|
return na === nb
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringsEqualInsensitive(a: string, b: string): boolean {
|
||||||
|
return a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNoReplyEmail(email: string): boolean {
|
||||||
|
const lower = email.trim().toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('noreply') ||
|
||||||
|
lower.includes('no-reply') ||
|
||||||
|
lower.includes('no_reply')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileHasNoReplyEmail(profile: ApiDiscoveredProfile): boolean {
|
||||||
|
if (isNoReplyEmail(profile.primary_email)) return true
|
||||||
|
for (const e of profile.all_emails ?? []) {
|
||||||
|
if (isNoReplyEmail(e.email)) return true
|
||||||
|
}
|
||||||
|
for (const e of profile.enriched_data?.emails ?? []) {
|
||||||
|
if (isNoReplyEmail(e.value)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestions shown in « Ajouter des coordonnées » (enrichissement de contacts existants). */
|
||||||
|
export function filterVisibleEnrichmentSuggestions(
|
||||||
|
rawSuggestions: ApiEnrichmentSuggestion[],
|
||||||
|
contacts: FullContact[],
|
||||||
|
): ApiEnrichmentSuggestion[] {
|
||||||
|
return rawSuggestions.filter((s) => {
|
||||||
|
if (s.suggestion_type !== 'enrich_contact' || !s.target_contact_uid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const contact = contacts.find((c) => c.id === s.target_contact_uid)
|
||||||
|
if (!contact) return true
|
||||||
|
return !isEnrichmentSuggestionRedundant(contact, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the suggestion value is already present on the contact. */
|
||||||
|
export function isEnrichmentSuggestionRedundant(
|
||||||
|
contact: FullContact,
|
||||||
|
suggestion: Pick<ApiEnrichmentSuggestion, 'field_path' | 'suggested_value'>,
|
||||||
|
): boolean {
|
||||||
|
const val = suggestion.suggested_value.trim()
|
||||||
|
if (!val) return true
|
||||||
|
|
||||||
|
switch (suggestion.field_path) {
|
||||||
|
case 'full_name': {
|
||||||
|
const full = `${contact.firstName} ${contact.lastName}`.trim()
|
||||||
|
return stringsEqualInsensitive(val, full)
|
||||||
|
}
|
||||||
|
case 'company':
|
||||||
|
return contact.company ? stringsEqualInsensitive(val, contact.company) : false
|
||||||
|
case 'job_title':
|
||||||
|
return contact.jobTitle ? stringsEqualInsensitive(val, contact.jobTitle) : false
|
||||||
|
case 'phones':
|
||||||
|
return contact.phones.some((p) => phonesMatch(p.value, val))
|
||||||
|
case 'emails':
|
||||||
|
return contact.emails.some((e) => stringsEqualInsensitive(e.value, val))
|
||||||
|
case 'social_profiles':
|
||||||
|
return (contact.socialProfiles ?? []).some((p) => stringsEqualInsensitive(p.value, val))
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIELD_LABELS: Record<string, string> = {
|
||||||
|
first_name: 'Prénom',
|
||||||
|
last_name: 'Nom',
|
||||||
|
company: 'Entreprise',
|
||||||
|
department: 'Service',
|
||||||
|
job_title: 'Poste',
|
||||||
|
emails: 'E-mail',
|
||||||
|
phones: 'Téléphone',
|
||||||
|
addresses: 'Adresse',
|
||||||
|
website: 'Site web',
|
||||||
|
social_profiles: 'Réseaux sociaux',
|
||||||
|
notes: 'Notes',
|
||||||
|
full_name: 'Nom complet',
|
||||||
|
}
|
||||||
@ -1,64 +1,9 @@
|
|||||||
import Fuse, { type IFuseOptions } from "fuse.js"
|
|
||||||
import type { FullContact } from "./types"
|
import type { FullContact } from "./types"
|
||||||
|
import { rankFullContacts } from "./contact-match-score"
|
||||||
function stripAccents(str: string): string {
|
|
||||||
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
const fuseOptions: IFuseOptions<FullContact> = {
|
|
||||||
keys: [
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"middleName",
|
|
||||||
"nicknames",
|
|
||||||
"emails.value",
|
|
||||||
"phones.value",
|
|
||||||
"company",
|
|
||||||
"department",
|
|
||||||
"jobTitle",
|
|
||||||
"addresses.street",
|
|
||||||
"addresses.city",
|
|
||||||
"addresses.country",
|
|
||||||
],
|
|
||||||
isCaseSensitive: false,
|
|
||||||
threshold: 0.4,
|
|
||||||
ignoreLocation: true,
|
|
||||||
getFn: (obj: FullContact, path: string | string[]) => {
|
|
||||||
const raw = Fuse.config.getFn(obj, path)
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
return raw.map((v) => (typeof v === "string" ? stripAccents(v) : v)) as unknown as string
|
|
||||||
}
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
return stripAccents(raw)
|
|
||||||
}
|
|
||||||
return raw as unknown as string
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedFuse: { key: FullContact[]; fuse: Fuse<FullContact> } | null = null
|
|
||||||
|
|
||||||
function getFuse(contacts: FullContact[]): Fuse<FullContact> {
|
|
||||||
if (cachedFuse && cachedFuse.key === contacts) return cachedFuse.fuse
|
|
||||||
const fuse = new Fuse(contacts, fuseOptions)
|
|
||||||
cachedFuse = { key: contacts, fuse }
|
|
||||||
return fuse
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchContacts(
|
export function searchContacts(
|
||||||
contacts: FullContact[],
|
contacts: FullContact[],
|
||||||
query: string
|
query: string
|
||||||
): FullContact[] {
|
): FullContact[] {
|
||||||
if (!query.trim()) return contacts
|
return rankFullContacts(contacts, query)
|
||||||
|
|
||||||
const fuse = getFuse(contacts)
|
|
||||||
const results = fuse.search(stripAccents(query))
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const deduped: FullContact[] = []
|
|
||||||
for (const r of results) {
|
|
||||||
if (!seen.has(r.item.id)) {
|
|
||||||
seen.add(r.item.id)
|
|
||||||
deduped.push(r.item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deduped
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { parseDisplayNameToNameParts } from "./find-contact"
|
import { parseDisplayNameToNameParts } from "./find-contact"
|
||||||
|
import { parseVCardPhoto } from "@/lib/contact-avatar"
|
||||||
import type { FullContact } from "./types"
|
import type { FullContact } from "./types"
|
||||||
|
|
||||||
export type ContactImportInput = Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
export type ContactImportInput = Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
||||||
@ -49,13 +50,13 @@ function unfoldVcardLines(text: string): string[] {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVcardProperty(line: string): { key: string; value: string } | null {
|
function parseVcardProperty(line: string): { key: string; rawKey: string; value: string } | null {
|
||||||
const idx = line.indexOf(":")
|
const idx = line.indexOf(":")
|
||||||
if (idx === -1) return null
|
if (idx === -1) return null
|
||||||
const left = line.slice(0, idx)
|
const rawKey = line.slice(0, idx)
|
||||||
const value = line.slice(idx + 1).trim()
|
const value = line.slice(idx + 1).trim()
|
||||||
const key = left.split(";")[0].toUpperCase()
|
const key = rawKey.split(";")[0].toUpperCase()
|
||||||
return { key, value }
|
return { key, rawKey, value }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
||||||
@ -66,11 +67,12 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
|||||||
const emails: { value: string; label: string }[] = []
|
const emails: { value: string; label: string }[] = []
|
||||||
const phones: { value: string; label: string }[] = []
|
const phones: { value: string; label: string }[] = []
|
||||||
let notes: string | undefined
|
let notes: string | undefined
|
||||||
|
let avatarUrl: string | undefined
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const prop = parseVcardProperty(line)
|
const prop = parseVcardProperty(line)
|
||||||
if (!prop || !prop.value) continue
|
if (!prop || !prop.value) continue
|
||||||
const { key, value } = prop
|
const { key, rawKey, value } = prop
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "FN": {
|
case "FN": {
|
||||||
@ -102,6 +104,9 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
|||||||
case "NOTE":
|
case "NOTE":
|
||||||
notes = value
|
notes = value
|
||||||
break
|
break
|
||||||
|
case "PHOTO":
|
||||||
|
avatarUrl = parseVCardPhoto(rawKey, value) ?? avatarUrl
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -119,6 +124,7 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
|||||||
emails,
|
emails,
|
||||||
phones,
|
phones,
|
||||||
notes,
|
notes,
|
||||||
|
avatarUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
172
lib/contacts/improve-contact.ts
Normal file
172
lib/contacts/improve-contact.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { buildVCardFromFullContact } from '@/lib/api/adapters'
|
||||||
|
import type { ApiEnrichedContactData } from '@/lib/contacts/discovery-types'
|
||||||
|
import type { FullContact } from '@/lib/contacts/types'
|
||||||
|
|
||||||
|
const REVERSE_LABEL_MAP: Record<string, string> = {
|
||||||
|
travail: 'work',
|
||||||
|
work: 'work',
|
||||||
|
domicile: 'home',
|
||||||
|
home: 'home',
|
||||||
|
mobile: 'mobile',
|
||||||
|
autre: 'other',
|
||||||
|
other: 'other',
|
||||||
|
personal: 'home',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_MAP: Record<string, string> = {
|
||||||
|
work: 'Travail',
|
||||||
|
home: 'Domicile',
|
||||||
|
mobile: 'Mobile',
|
||||||
|
other: 'Autre',
|
||||||
|
linkedin: 'LinkedIn',
|
||||||
|
twitter: 'X / Twitter',
|
||||||
|
facebook: 'Facebook',
|
||||||
|
instagram: 'Instagram',
|
||||||
|
github: 'GitHub',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiLabel(label?: string): string {
|
||||||
|
if (!label) return 'other'
|
||||||
|
const key = label.trim().toLowerCase()
|
||||||
|
return REVERSE_LABEL_MAP[key] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromApiLabel(label?: string): string {
|
||||||
|
if (!label) return 'Autre'
|
||||||
|
return LABEL_MAP[label.toLowerCase()] ?? label
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBirthdayForPayload(b?: FullContact['birthday']): string | undefined {
|
||||||
|
if (!b?.month && !b?.day && !b?.year) return undefined
|
||||||
|
const y = b.year ? String(b.year).padStart(4, '0') : ''
|
||||||
|
const m = b.month ? String(b.month).padStart(2, '0') : ''
|
||||||
|
const d = b.day ? String(b.day).padStart(2, '0') : ''
|
||||||
|
if (y && m && d) return `${y}-${m}-${d}`
|
||||||
|
if (m && d) return `--${m}-${d}`
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImproveContactPayload {
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
middle_name?: string
|
||||||
|
company?: string
|
||||||
|
department?: string
|
||||||
|
job_title?: string
|
||||||
|
website?: string
|
||||||
|
notes?: string
|
||||||
|
emails: { value: string; label: string }[]
|
||||||
|
phones: { value: string; label: string }[]
|
||||||
|
social_profiles?: { value: string; label: string }[]
|
||||||
|
addresses?: {
|
||||||
|
street?: string
|
||||||
|
city?: string
|
||||||
|
region?: string
|
||||||
|
postal_code?: string
|
||||||
|
country?: string
|
||||||
|
label: string
|
||||||
|
}[]
|
||||||
|
birthday?: string
|
||||||
|
raw_vcard?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullContactToImprovePayload(contact: FullContact): ImproveContactPayload {
|
||||||
|
return {
|
||||||
|
first_name: contact.firstName,
|
||||||
|
last_name: contact.lastName,
|
||||||
|
middle_name: contact.middleName,
|
||||||
|
company: contact.company,
|
||||||
|
department: contact.department,
|
||||||
|
job_title: contact.jobTitle,
|
||||||
|
website: contact.website,
|
||||||
|
notes: contact.notes,
|
||||||
|
social_profiles: contact.socialProfiles?.map((p) => ({
|
||||||
|
value: p.value,
|
||||||
|
label: toApiLabel(p.label),
|
||||||
|
})),
|
||||||
|
emails: contact.emails.map((e) => ({ value: e.value, label: toApiLabel(e.label) })),
|
||||||
|
phones: contact.phones.map((p) => ({ value: p.value, label: toApiLabel(p.label) })),
|
||||||
|
addresses: contact.addresses?.map((a) => ({
|
||||||
|
street: a.street,
|
||||||
|
city: a.city,
|
||||||
|
region: a.region,
|
||||||
|
postal_code: a.postalCode,
|
||||||
|
country: a.country,
|
||||||
|
label: toApiLabel(a.label),
|
||||||
|
})),
|
||||||
|
birthday: formatBirthdayForPayload(contact.birthday),
|
||||||
|
raw_vcard: buildVCardFromFullContact(contact),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeImprovedContact(
|
||||||
|
contact: FullContact,
|
||||||
|
data: ApiEnrichedContactData,
|
||||||
|
): FullContact {
|
||||||
|
const emails = data.emails?.length
|
||||||
|
? data.emails.map((e) => ({ value: e.value, label: fromApiLabel(e.label) }))
|
||||||
|
: contact.emails
|
||||||
|
|
||||||
|
const phones = data.phones?.length
|
||||||
|
? data.phones.map((p) => ({ value: p.value, label: fromApiLabel(p.label) }))
|
||||||
|
: contact.phones
|
||||||
|
|
||||||
|
const addresses = data.addresses?.length
|
||||||
|
? data.addresses.map((a) => ({
|
||||||
|
street: a.street,
|
||||||
|
city: a.city,
|
||||||
|
region: a.region,
|
||||||
|
postalCode: a.postal_code,
|
||||||
|
country: a.country,
|
||||||
|
label: fromApiLabel(a.label),
|
||||||
|
}))
|
||||||
|
: contact.addresses
|
||||||
|
|
||||||
|
const socialProfiles = data.social_profiles?.length
|
||||||
|
? data.social_profiles.map((p) => ({ value: p.value, label: fromApiLabel(p.label) }))
|
||||||
|
: contact.socialProfiles
|
||||||
|
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
firstName: data.first_name?.trim() || contact.firstName,
|
||||||
|
lastName: data.last_name?.trim() || contact.lastName,
|
||||||
|
company: data.company?.trim() || contact.company,
|
||||||
|
department: data.department?.trim() || contact.department,
|
||||||
|
jobTitle: data.job_title?.trim() || contact.jobTitle,
|
||||||
|
website: data.website?.trim() || contact.website,
|
||||||
|
notes: data.notes?.trim() || contact.notes,
|
||||||
|
emails,
|
||||||
|
phones,
|
||||||
|
addresses,
|
||||||
|
socialProfiles,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function improvedDataToDraftFields(data: ApiEnrichedContactData) {
|
||||||
|
const items: { id: string; fieldKey: string; value: string }[] = []
|
||||||
|
let i = 0
|
||||||
|
const push = (fieldKey: string, value: string | undefined) => {
|
||||||
|
const v = value?.trim()
|
||||||
|
if (!v) return
|
||||||
|
items.push({ id: `${fieldKey}-${i++}`, fieldKey, value: v })
|
||||||
|
}
|
||||||
|
|
||||||
|
push('first_name', data.first_name)
|
||||||
|
push('last_name', data.last_name)
|
||||||
|
push('company', data.company)
|
||||||
|
push('department', data.department)
|
||||||
|
push('job_title', data.job_title)
|
||||||
|
push('website', data.website)
|
||||||
|
push('notes', data.notes)
|
||||||
|
for (const p of data.social_profiles ?? []) push('social_profiles', p.value)
|
||||||
|
for (const e of data.emails ?? []) push('emails', e.value)
|
||||||
|
for (const p of data.phones ?? []) push('phones', p.value)
|
||||||
|
for (const a of data.addresses ?? []) {
|
||||||
|
const line = [a.street, [a.postal_code, a.city].filter(Boolean).join(' '), a.region, a.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
push('addresses', line)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
6
lib/contacts/llm-settings-utils.ts
Normal file
6
lib/contacts/llm-settings-utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import type { ApiLLMSettings } from '@/lib/contacts/discovery-types'
|
||||||
|
|
||||||
|
export function isLLMConfigured(settings?: ApiLLMSettings): boolean {
|
||||||
|
if (!settings?.providers?.length) return false
|
||||||
|
return settings.providers.some((p) => Boolean(p.base_url?.trim()))
|
||||||
|
}
|
||||||
138
lib/contacts/merge-contacts.ts
Normal file
138
lib/contacts/merge-contacts.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import type { FullContact } from "./types"
|
||||||
|
import { fullContactDisplayName } from "./types"
|
||||||
|
|
||||||
|
function contactMergeScore(c: FullContact): number {
|
||||||
|
let score = 0
|
||||||
|
if (fullContactDisplayName(c)) score += 3
|
||||||
|
if (c.emails.some((e) => e.value.trim())) score += 4
|
||||||
|
if (c.phones.some((p) => p.value.trim())) score += 2
|
||||||
|
if (c.company?.trim()) score += 1
|
||||||
|
if (c.path?.trim()) score += 1
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickContactMergePrimary(contacts: FullContact[]): FullContact | undefined {
|
||||||
|
if (contacts.length === 0) return undefined
|
||||||
|
return [...contacts].sort((a, b) => contactMergeScore(b) - contactMergeScore(a))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeManyContacts(
|
||||||
|
contacts: FullContact[],
|
||||||
|
primaryId?: string,
|
||||||
|
): { primary: FullContact; secondaries: FullContact[]; merged: FullContact } | null {
|
||||||
|
if (contacts.length < 2) return null
|
||||||
|
|
||||||
|
const primary =
|
||||||
|
(primaryId ? contacts.find((c) => c.id === primaryId) : undefined) ??
|
||||||
|
pickContactMergePrimary(contacts)
|
||||||
|
if (!primary) return null
|
||||||
|
|
||||||
|
const secondaries = contacts.filter((c) => c.id !== primary.id)
|
||||||
|
let merged = primary
|
||||||
|
for (const secondary of secondaries) {
|
||||||
|
merged = mergeFullContactFields(merged, secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
secondaries,
|
||||||
|
merged: {
|
||||||
|
...merged,
|
||||||
|
id: primary.id,
|
||||||
|
path: primary.path,
|
||||||
|
etag: primary.etag,
|
||||||
|
createdAt: primary.createdAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeLabeledValues<T extends { value: string; label: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: T[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.value.trim().toLowerCase()
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeAddresses(items: NonNullable<FullContact["addresses"]>): NonNullable<FullContact["addresses"]> {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: NonNullable<FullContact["addresses"]> = []
|
||||||
|
for (const item of items) {
|
||||||
|
const key = [
|
||||||
|
item.street,
|
||||||
|
item.city,
|
||||||
|
item.region,
|
||||||
|
item.postalCode,
|
||||||
|
item.country,
|
||||||
|
item.label,
|
||||||
|
]
|
||||||
|
.map((part) => part?.trim().toLowerCase() ?? "")
|
||||||
|
.join("|")
|
||||||
|
if (!key.replace(/\|/g, "") || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeStrings(items: string[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.trim().toLowerCase()
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNotes(a?: string, b?: string): string | undefined {
|
||||||
|
const parts = [a, b]
|
||||||
|
.map((n) => n?.trim())
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (parts.length === 0) return undefined
|
||||||
|
return dedupeStrings(parts).join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFullContactFields(primary: FullContact, secondary: FullContact): FullContact {
|
||||||
|
return {
|
||||||
|
...primary,
|
||||||
|
namePrefix: primary.namePrefix || secondary.namePrefix,
|
||||||
|
firstName: primary.firstName || secondary.firstName,
|
||||||
|
middleName: primary.middleName || secondary.middleName,
|
||||||
|
lastName: primary.lastName || secondary.lastName,
|
||||||
|
nameSuffix: primary.nameSuffix || secondary.nameSuffix,
|
||||||
|
phoneticFirstName: primary.phoneticFirstName || secondary.phoneticFirstName,
|
||||||
|
phoneticLastName: primary.phoneticLastName || secondary.phoneticLastName,
|
||||||
|
company: primary.company || secondary.company,
|
||||||
|
department: primary.department || secondary.department,
|
||||||
|
jobTitle: primary.jobTitle || secondary.jobTitle,
|
||||||
|
website: primary.website || secondary.website,
|
||||||
|
notes: mergeNotes(primary.notes, secondary.notes),
|
||||||
|
emails: dedupeLabeledValues([...primary.emails, ...secondary.emails]),
|
||||||
|
phones: dedupeLabeledValues([...primary.phones, ...secondary.phones]),
|
||||||
|
addresses: dedupeAddresses([
|
||||||
|
...(primary.addresses ?? []),
|
||||||
|
...(secondary.addresses ?? []),
|
||||||
|
]),
|
||||||
|
nicknames: dedupeStrings([...(primary.nicknames ?? []), ...(secondary.nicknames ?? [])]),
|
||||||
|
labels: dedupeStrings([...(primary.labels ?? []), ...(secondary.labels ?? [])]),
|
||||||
|
avatarUrl: primary.avatarUrl || secondary.avatarUrl,
|
||||||
|
birthday: primary.birthday ?? secondary.birthday,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTwoContacts(
|
||||||
|
a: FullContact,
|
||||||
|
b: FullContact,
|
||||||
|
): { primary: FullContact; secondary: FullContact; merged: FullContact } {
|
||||||
|
const [primary, secondary] =
|
||||||
|
contactMergeScore(a) >= contactMergeScore(b) ? [a, b] : [b, a]
|
||||||
|
return { primary, secondary, merged: mergeFullContactFields(primary, secondary) }
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ export interface ContactAddress {
|
|||||||
|
|
||||||
export interface FullContact {
|
export interface FullContact {
|
||||||
id: string
|
id: string
|
||||||
|
/** Chemin CardDAV (PUT/DELETE). Préférer à id pour les appels API. */
|
||||||
|
path?: string
|
||||||
|
etag?: string
|
||||||
namePrefix?: string
|
namePrefix?: string
|
||||||
firstName: string
|
firstName: string
|
||||||
middleName?: string
|
middleName?: string
|
||||||
@ -22,6 +25,8 @@ export interface FullContact {
|
|||||||
company?: string
|
company?: string
|
||||||
department?: string
|
department?: string
|
||||||
jobTitle?: string
|
jobTitle?: string
|
||||||
|
website?: string
|
||||||
|
socialProfiles?: { value: string; label: string }[]
|
||||||
emails: { value: string; label: string }[]
|
emails: { value: string; label: string }[]
|
||||||
phones: { value: string; label: string }[]
|
phones: { value: string; label: string }[]
|
||||||
addresses?: ContactAddress[]
|
addresses?: ContactAddress[]
|
||||||
|
|||||||
@ -2,18 +2,37 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
|
useContactBooks,
|
||||||
useContacts,
|
useContacts,
|
||||||
useDefaultContactBookId,
|
useDefaultContactBookId,
|
||||||
} from '@/lib/api/hooks/use-contact-queries'
|
} from '@/lib/api/hooks/use-contact-queries'
|
||||||
import { apiContactToFullContact } from '@/lib/api/adapters'
|
import { mapApiContactsToFullContacts } from '@/lib/api/contact-list-cache'
|
||||||
|
|
||||||
export function useContactsList(bookId?: string) {
|
export function useContactsList(bookId?: string) {
|
||||||
const defaultBookId = useDefaultContactBookId()
|
const defaultBookId = useDefaultContactBookId()
|
||||||
const resolvedBookId = bookId ?? defaultBookId
|
const resolvedBookId = bookId ?? defaultBookId
|
||||||
|
const booksQuery = useContactBooks()
|
||||||
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
|
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
|
||||||
const contacts = useMemo(
|
const contacts = useMemo(
|
||||||
() => apiContacts?.map(apiContactToFullContact) ?? [],
|
() => mapApiContactsToFullContacts(resolvedBookId ?? '', apiContacts),
|
||||||
[apiContacts]
|
[resolvedBookId, apiContacts],
|
||||||
)
|
)
|
||||||
return { contacts, bookId: resolvedBookId, ...rest }
|
const isLoading = booksQuery.isLoading || (!!resolvedBookId && rest.isLoading)
|
||||||
|
const isError = booksQuery.isError || rest.isError
|
||||||
|
const error = booksQuery.error ?? rest.error
|
||||||
|
|
||||||
|
function refetch() {
|
||||||
|
void booksQuery.refetch()
|
||||||
|
void rest.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacts,
|
||||||
|
bookId: resolvedBookId,
|
||||||
|
...rest,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { Email } from "@/lib/email-data"
|
import type { Email } from "@/lib/email-data"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import {
|
||||||
|
fieldMatchScore,
|
||||||
|
normalizeContactSearchText,
|
||||||
|
} from "@/lib/contacts/contact-match-score"
|
||||||
import type { SearchParams } from "./search-params"
|
import type { SearchParams } from "./search-params"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -29,21 +34,7 @@ export type SearchSuggestion = ContactSuggestion | EmailSuggestion
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function normalize(s: string): string {
|
function normalize(s: string): string {
|
||||||
return s
|
return normalizeContactSearchText(s)
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefixScore(haystack: string, needle: string): number {
|
|
||||||
const h = normalize(haystack)
|
|
||||||
const n = normalize(needle)
|
|
||||||
if (!n) return 0
|
|
||||||
if (h === n) return 1
|
|
||||||
if (h.startsWith(n)) return 0.9 + 0.1 * (n.length / h.length)
|
|
||||||
const idx = h.indexOf(n)
|
|
||||||
if (idx > 0) return 0.5 + 0.4 * (n.length / h.length)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -58,16 +49,16 @@ export function matchContacts(
|
|||||||
if (!query.trim()) return []
|
if (!query.trim()) return []
|
||||||
const results: ContactSuggestion[] = []
|
const results: ContactSuggestion[] = []
|
||||||
for (const c of contacts) {
|
for (const c of contacts) {
|
||||||
const fullName = `${c.firstName} ${c.lastName}`.trim()
|
const fullName = fullContactDisplayName(c)
|
||||||
let bestScore = 0
|
let bestScore = 0
|
||||||
let bestEmail = c.emails[0]?.value ?? ""
|
let bestEmail = c.emails[0]?.value ?? ""
|
||||||
|
|
||||||
bestScore = Math.max(bestScore, prefixScore(fullName, query))
|
bestScore = Math.max(bestScore, fieldMatchScore(fullName, query))
|
||||||
bestScore = Math.max(bestScore, prefixScore(c.firstName, query))
|
bestScore = Math.max(bestScore, fieldMatchScore(c.firstName, query))
|
||||||
bestScore = Math.max(bestScore, prefixScore(c.lastName, query))
|
bestScore = Math.max(bestScore, fieldMatchScore(c.lastName, query))
|
||||||
|
|
||||||
for (const e of c.emails) {
|
for (const e of c.emails) {
|
||||||
const s = prefixScore(e.value, query)
|
const s = fieldMatchScore(e.value, query)
|
||||||
if (s > bestScore) {
|
if (s > bestScore) {
|
||||||
bestScore = s
|
bestScore = s
|
||||||
bestEmail = e.value
|
bestEmail = e.value
|
||||||
@ -104,8 +95,8 @@ export function matchEmails(
|
|||||||
if (!e.senderEmail) continue
|
if (!e.senderEmail) continue
|
||||||
const addr = e.senderEmail.toLowerCase()
|
const addr = e.senderEmail.toLowerCase()
|
||||||
const name = e.sender
|
const name = e.sender
|
||||||
const s1 = prefixScore(addr, query)
|
const s1 = fieldMatchScore(addr, query)
|
||||||
const s2 = prefixScore(name, query)
|
const s2 = fieldMatchScore(name, query)
|
||||||
const s = Math.max(s1, s2)
|
const s = Math.max(s1, s2)
|
||||||
if (s > 0) {
|
if (s > 0) {
|
||||||
const existing = seen.get(addr)
|
const existing = seen.get(addr)
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
|
|||||||
{
|
{
|
||||||
id: "automation",
|
id: "automation",
|
||||||
label: "Automatisations",
|
label: "Automatisations",
|
||||||
description: "Règles, webhooks, LLM, tokens API",
|
description: "Règles, webhooks, LLM, recherche web, tokens API",
|
||||||
href: "/mail/settings/automation",
|
href: "/mail/settings/automation",
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
},
|
},
|
||||||
|
|||||||
54
lib/stores/blocked-senders-store.ts
Normal file
54
lib/stores/blocked-senders-store.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { persist } from "zustand/middleware"
|
||||||
|
import { normalizeEmail } from "@/lib/contacts/find-contact"
|
||||||
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||||
|
|
||||||
|
type BlockedSendersState = {
|
||||||
|
blockedSenderEmails: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockedSendersActions = {
|
||||||
|
blockSenders: (emails: string[]) => void
|
||||||
|
unblockSender: (email: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockedSenderEmail(
|
||||||
|
blockedSenderEmails: string[],
|
||||||
|
email: string,
|
||||||
|
): boolean {
|
||||||
|
const norm = normalizeEmail(email)
|
||||||
|
return norm ? blockedSenderEmails.includes(norm) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBlockedSendersStore = create<
|
||||||
|
BlockedSendersState & BlockedSendersActions
|
||||||
|
>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
blockedSenderEmails: [],
|
||||||
|
|
||||||
|
blockSenders: (emails) => {
|
||||||
|
const next = new Set(get().blockedSenderEmails)
|
||||||
|
for (const email of emails) {
|
||||||
|
const norm = normalizeEmail(email)
|
||||||
|
if (norm) next.add(norm)
|
||||||
|
}
|
||||||
|
set({ blockedSenderEmails: [...next] })
|
||||||
|
},
|
||||||
|
|
||||||
|
unblockSender: (email) => {
|
||||||
|
const norm = normalizeEmail(email)
|
||||||
|
if (!norm) return
|
||||||
|
set((s) => ({
|
||||||
|
blockedSenderEmails: s.blockedSenderEmails.filter((e) => e !== norm),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ultimail-blocked-senders",
|
||||||
|
storage: debouncedPersistJSONStorage,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@iconify-json/cbi": "^1.2.36",
|
"@iconify-json/cbi": "^1.2.36",
|
||||||
"@iconify-json/fluent": "^1.2.47",
|
"@iconify-json/fluent": "^1.2.47",
|
||||||
|
|||||||
@ -14,6 +14,9 @@ importers:
|
|||||||
'@emoji-mart/react':
|
'@emoji-mart/react':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
|
version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
|
||||||
|
'@formkit/auto-animate':
|
||||||
|
specifier: ^0.9.0
|
||||||
|
version: 0.9.0
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^3.9.1
|
specifier: ^3.9.1
|
||||||
version: 3.10.0(react-hook-form@7.71.1(react@19.2.4))
|
version: 3.10.0(react-hook-form@7.71.1(react@19.2.4))
|
||||||
@ -305,6 +308,9 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.10':
|
'@floating-ui/utils@0.2.10':
|
||||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
|
'@formkit/auto-animate@0.9.0':
|
||||||
|
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0':
|
'@hookform/resolvers@3.10.0':
|
||||||
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2233,6 +2239,8 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.10': {}
|
'@floating-ui/utils@0.2.10': {}
|
||||||
|
|
||||||
|
'@formkit/auto-animate@0.9.0': {}
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0(react-hook-form@7.71.1(react@19.2.4))':
|
'@hookform/resolvers@3.10.0(react-hook-form@7.71.1(react@19.2.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
react-hook-form: 7.71.1(react@19.2.4)
|
react-hook-form: 7.71.1(react@19.2.4)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user