ultisuite-client/lib/contacts/discovery-utils.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

242 lines
7.2 KiB
TypeScript

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',
}