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.
242 lines
7.2 KiB
TypeScript
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',
|
|
}
|