332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { create } from "zustand"
|
|
import { persist } from "zustand/middleware"
|
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
|
import {
|
|
findDuplicatePairs,
|
|
mergePairKey,
|
|
normalizePhone,
|
|
type DuplicateMatchReason,
|
|
} from "./duplicate-detection"
|
|
import { MOCK_FULL_CONTACTS } from "./mock-data"
|
|
import type { FullContact } from "./types"
|
|
|
|
type ContactsView = "list" | "view" | "create" | "edit"
|
|
|
|
/** Prefill for "Nouveau contact" opened from hover card / elsewhere. */
|
|
export type ContactCreateDraft = {
|
|
firstName?: string
|
|
lastName?: string
|
|
emails?: { value: string; label: string }[]
|
|
}
|
|
|
|
export interface DeletedContact {
|
|
contact: FullContact
|
|
deletedAt: number
|
|
reason: string
|
|
}
|
|
|
|
export interface MergeSuggestion {
|
|
contactA: FullContact
|
|
contactB: FullContact
|
|
reason: DuplicateMatchReason
|
|
}
|
|
|
|
export interface CoordinateSuggestion {
|
|
contact: FullContact
|
|
suggestedField: string
|
|
suggestedValue: string
|
|
}
|
|
|
|
interface ContactsState {
|
|
contacts: FullContact[]
|
|
deletedContacts: DeletedContact[]
|
|
ignoredMergePairs: string[]
|
|
panelOpen: boolean
|
|
view: ContactsView
|
|
activeContactId: string | null
|
|
searchQuery: string
|
|
searchMode: boolean
|
|
createDraft: ContactCreateDraft | null
|
|
}
|
|
|
|
interface ContactsActions {
|
|
togglePanel: () => void
|
|
openPanel: () => void
|
|
closePanel: () => void
|
|
openContactDetail: (contactId: string) => void
|
|
openCreateContact: (draft?: ContactCreateDraft | null) => void
|
|
clearCreateDraft: () => void
|
|
setView: (view: ContactsView, activeContactId?: string | null) => void
|
|
setSearchQuery: (q: string) => void
|
|
setSearchMode: (active: boolean) => void
|
|
addContact: (
|
|
contact: Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
|
) => string
|
|
addContacts: (
|
|
contacts: Omit<FullContact, "id" | "createdAt" | "updatedAt">[]
|
|
) => number
|
|
updateContact: (id: string, patch: Partial<FullContact>) => void
|
|
deleteContact: (id: string) => void
|
|
softDeleteContact: (id: string, reason?: string) => void
|
|
restoreContact: (id: string) => void
|
|
emptyTrash: () => void
|
|
mergeContacts: (keepId: string, mergeId: string) => void
|
|
ignoreMergePair: (idA: string, idB: string) => void
|
|
getMergeSuggestions: () => MergeSuggestion[]
|
|
getCoordinateSuggestions: () => CoordinateSuggestion[]
|
|
}
|
|
|
|
export type ContactsStore = ContactsState & ContactsActions
|
|
|
|
function computeCoordinateSuggestions(contacts: FullContact[]): CoordinateSuggestion[] {
|
|
const suggestions: CoordinateSuggestion[] = []
|
|
const emailDomains = new Map<string, { company?: string; jobTitle?: string }>()
|
|
|
|
for (const c of contacts) {
|
|
if (c.company) {
|
|
for (const e of c.emails) {
|
|
const domain = e.value.split("@")[1]?.toLowerCase()
|
|
if (domain && !domain.includes("gmail") && !domain.includes("outlook") && !domain.includes("yahoo") && !domain.includes("proton")) {
|
|
emailDomains.set(domain, { company: c.company, jobTitle: c.jobTitle })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const c of contacts) {
|
|
if (c.company) continue
|
|
for (const e of c.emails) {
|
|
const domain = e.value.split("@")[1]?.toLowerCase()
|
|
if (domain && emailDomains.has(domain)) {
|
|
const info = emailDomains.get(domain)!
|
|
if (info.company) {
|
|
suggestions.push({ contact: c, suggestedField: "company", suggestedValue: info.company })
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (suggestions.length >= 20) break
|
|
}
|
|
return suggestions
|
|
}
|
|
|
|
export const useContactsStore = create<ContactsStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
contacts: MOCK_FULL_CONTACTS,
|
|
deletedContacts: [],
|
|
ignoredMergePairs: [],
|
|
panelOpen: false,
|
|
view: "list",
|
|
activeContactId: null,
|
|
searchQuery: "",
|
|
searchMode: false,
|
|
createDraft: null,
|
|
|
|
togglePanel: () =>
|
|
set((s) =>
|
|
s.panelOpen
|
|
? {
|
|
panelOpen: false,
|
|
view: "list",
|
|
activeContactId: null,
|
|
searchQuery: "",
|
|
searchMode: false,
|
|
createDraft: null,
|
|
}
|
|
: { panelOpen: true }
|
|
),
|
|
|
|
openPanel: () => set({ panelOpen: true }),
|
|
|
|
closePanel: () =>
|
|
set({
|
|
panelOpen: false,
|
|
view: "list",
|
|
activeContactId: null,
|
|
searchQuery: "",
|
|
searchMode: false,
|
|
createDraft: null,
|
|
}),
|
|
|
|
openContactDetail: (contactId) =>
|
|
set({
|
|
panelOpen: true,
|
|
view: "view",
|
|
activeContactId: contactId,
|
|
searchQuery: "",
|
|
searchMode: false,
|
|
createDraft: null,
|
|
}),
|
|
|
|
openCreateContact: (draft = null) =>
|
|
set({
|
|
panelOpen: true,
|
|
view: "create",
|
|
activeContactId: null,
|
|
searchQuery: "",
|
|
searchMode: false,
|
|
createDraft: draft,
|
|
}),
|
|
|
|
clearCreateDraft: () => set({ createDraft: null }),
|
|
|
|
setView: (view, activeContactId = null) =>
|
|
set({ view, activeContactId, createDraft: null }),
|
|
|
|
setSearchQuery: (searchQuery) => set({ searchQuery }),
|
|
|
|
setSearchMode: (searchMode) =>
|
|
set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }),
|
|
|
|
addContact: (contact) => {
|
|
const id = `contact-${crypto.randomUUID()}`
|
|
const now = Date.now()
|
|
const full: FullContact = { ...contact, id, createdAt: now, updatedAt: now }
|
|
set((s) => ({ contacts: [...s.contacts, full] }))
|
|
return id
|
|
},
|
|
|
|
addContacts: (incoming) => {
|
|
if (incoming.length === 0) return 0
|
|
const now = Date.now()
|
|
const added = incoming.map((contact) => ({
|
|
...contact,
|
|
id: `contact-${crypto.randomUUID()}`,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}))
|
|
set((s) => ({ contacts: [...s.contacts, ...added] }))
|
|
return added.length
|
|
},
|
|
|
|
updateContact: (id, patch) =>
|
|
set((s) => ({
|
|
contacts: s.contacts.map((c) =>
|
|
c.id === id ? { ...c, ...patch, updatedAt: Date.now() } : c
|
|
),
|
|
})),
|
|
|
|
deleteContact: (id) =>
|
|
set((s) => ({
|
|
contacts: s.contacts.filter((c) => c.id !== id),
|
|
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
|
view: s.activeContactId === id ? "list" : s.view,
|
|
})),
|
|
|
|
softDeleteContact: (id, reason = "Supprimé manuellement") =>
|
|
set((s) => {
|
|
const contact = s.contacts.find((c) => c.id === id)
|
|
if (!contact) return s
|
|
return {
|
|
contacts: s.contacts.filter((c) => c.id !== id),
|
|
deletedContacts: [
|
|
...s.deletedContacts,
|
|
{ contact, deletedAt: Date.now(), reason },
|
|
],
|
|
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
|
view: s.activeContactId === id ? "list" : s.view,
|
|
}
|
|
}),
|
|
|
|
restoreContact: (id) =>
|
|
set((s) => {
|
|
const entry = s.deletedContacts.find((d) => d.contact.id === id)
|
|
if (!entry) return s
|
|
return {
|
|
contacts: [...s.contacts, entry.contact],
|
|
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
|
|
}
|
|
}),
|
|
|
|
emptyTrash: () => set({ deletedContacts: [] }),
|
|
|
|
mergeContacts: (keepId, mergeId) =>
|
|
set((s) => {
|
|
const keep = s.contacts.find((c) => c.id === keepId)
|
|
const merge = s.contacts.find((c) => c.id === mergeId)
|
|
if (!keep || !merge) return s
|
|
|
|
const mergedEmails = [...keep.emails]
|
|
for (const e of merge.emails) {
|
|
if (!mergedEmails.some((me) => me.value.toLowerCase() === e.value.toLowerCase())) {
|
|
mergedEmails.push(e)
|
|
}
|
|
}
|
|
const mergedPhones = [...keep.phones]
|
|
for (const p of merge.phones) {
|
|
const norm = normalizePhone(p.value)
|
|
if (
|
|
!mergedPhones.some(
|
|
(mp) => normalizePhone(mp.value) === norm && norm.length > 0
|
|
)
|
|
) {
|
|
mergedPhones.push(p)
|
|
}
|
|
}
|
|
|
|
const mergedLabels = [
|
|
...new Set([...(keep.labels ?? []), ...(merge.labels ?? [])]),
|
|
]
|
|
|
|
const merged: FullContact = {
|
|
...keep,
|
|
firstName: keep.firstName || merge.firstName,
|
|
lastName: keep.lastName || merge.lastName,
|
|
emails: mergedEmails,
|
|
phones: mergedPhones,
|
|
labels: mergedLabels.length ? mergedLabels : undefined,
|
|
company: keep.company || merge.company,
|
|
jobTitle: keep.jobTitle || merge.jobTitle,
|
|
department: keep.department || merge.department,
|
|
birthday: keep.birthday || merge.birthday,
|
|
avatarUrl: keep.avatarUrl || merge.avatarUrl,
|
|
notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || undefined,
|
|
updatedAt: Date.now(),
|
|
}
|
|
|
|
const pairKey = mergePairKey(keepId, mergeId)
|
|
|
|
return {
|
|
contacts: s.contacts
|
|
.filter((c) => c.id !== mergeId)
|
|
.map((c) => (c.id === keepId ? merged : c)),
|
|
ignoredMergePairs: s.ignoredMergePairs.includes(pairKey)
|
|
? s.ignoredMergePairs
|
|
: [...s.ignoredMergePairs, pairKey],
|
|
}
|
|
}),
|
|
|
|
ignoreMergePair: (idA, idB) =>
|
|
set((s) => {
|
|
const key = mergePairKey(idA, idB)
|
|
if (s.ignoredMergePairs.includes(key)) return s
|
|
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
|
|
}),
|
|
|
|
getMergeSuggestions: () => {
|
|
const s = get()
|
|
const ignored = new Set(s.ignoredMergePairs)
|
|
return findDuplicatePairs(s.contacts, ignored).map((p) => ({
|
|
contactA: p.contactA,
|
|
contactB: p.contactB,
|
|
reason: p.reason,
|
|
}))
|
|
},
|
|
|
|
getCoordinateSuggestions: () => computeCoordinateSuggestions(get().contacts),
|
|
}),
|
|
{
|
|
name: "contacts-store",
|
|
storage: debouncedPersistJSONStorage,
|
|
partialize: (state) => ({
|
|
contacts: state.contacts,
|
|
deletedContacts: state.deletedContacts,
|
|
ignoredMergePairs: state.ignoredMergePairs,
|
|
}),
|
|
}
|
|
)
|
|
)
|