Hehe
This commit is contained in:
parent
18cb1257f4
commit
ae54fa29e4
@ -19,6 +19,7 @@ import { Sidebar } from "@/components/gmail/sidebar"
|
|||||||
import { Header } from "@/components/gmail/header"
|
import { Header } from "@/components/gmail/header"
|
||||||
import { EmailList } from "@/components/gmail/email-list"
|
import { EmailList } from "@/components/gmail/email-list"
|
||||||
import { RightPanel } from "@/components/gmail/right-panel"
|
import { RightPanel } from "@/components/gmail/right-panel"
|
||||||
|
import { ContactsPanel } from "@/components/gmail/contacts/contacts-panel"
|
||||||
import { EmailDragProvider } from "@/lib/drag-context"
|
import { EmailDragProvider } from "@/lib/drag-context"
|
||||||
import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator"
|
import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator"
|
||||||
import { ComposeProvider } from "@/lib/compose-context"
|
import { ComposeProvider } from "@/lib/compose-context"
|
||||||
@ -179,6 +180,7 @@ function MailAppInner() {
|
|||||||
>
|
>
|
||||||
<RightPanel />
|
<RightPanel />
|
||||||
</div>
|
</div>
|
||||||
|
<ContactsPanel />
|
||||||
</div>
|
</div>
|
||||||
{!splitView ? (
|
{!splitView ? (
|
||||||
<MobileBottomBar
|
<MobileBottomBar
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { MouseEvent, ReactNode } from "react"
|
import type { MouseEvent, ReactNode } from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@ -24,6 +24,11 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useComposeActions } from "@/lib/compose-context"
|
import { useComposeActions } from "@/lib/compose-context"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import {
|
||||||
|
findContactByEmail,
|
||||||
|
parseDisplayNameToNameParts,
|
||||||
|
} from "@/lib/contacts/find-contact"
|
||||||
import { useLongPress } from "@/hooks/use-long-press"
|
import { useLongPress } from "@/hooks/use-long-press"
|
||||||
import { useCoarsePointer } from "@/hooks/use-touch-nav"
|
import { useCoarsePointer } from "@/hooks/use-touch-nav"
|
||||||
|
|
||||||
@ -50,6 +55,9 @@ export function ContactHoverCard({
|
|||||||
side = "bottom",
|
side = "bottom",
|
||||||
}: ContactHoverCardProps) {
|
}: ContactHoverCardProps) {
|
||||||
const { openComposeWithInitial } = useComposeActions()
|
const { openComposeWithInitial } = useComposeActions()
|
||||||
|
const contacts = useContactsStore((s) => s.contacts)
|
||||||
|
const openContactDetail = useContactsStore((s) => s.openContactDetail)
|
||||||
|
const openCreateContact = useContactsStore((s) => s.openCreateContact)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const coarsePointer = useCoarsePointer()
|
const coarsePointer = useCoarsePointer()
|
||||||
const triggerRef = useRef<HTMLSpanElement>(null)
|
const triggerRef = useRef<HTMLSpanElement>(null)
|
||||||
@ -60,6 +68,25 @@ export function ContactHoverCard({
|
|||||||
const email = resolveSenderEmail(displayName, emailOverride)
|
const email = resolveSenderEmail(displayName, emailOverride)
|
||||||
const color = avatarColor(name)
|
const color = avatarColor(name)
|
||||||
|
|
||||||
|
const matchedContact = useMemo(
|
||||||
|
() => findContactByEmail(contacts, email),
|
||||||
|
[contacts, email],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openContactsPanel = useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
if (matchedContact) {
|
||||||
|
openContactDetail(matchedContact.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { firstName, lastName } = parseDisplayNameToNameParts(name)
|
||||||
|
openCreateContact({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
emails: email ? [{ value: email, label: "Domicile" }] : undefined,
|
||||||
|
})
|
||||||
|
}, [matchedContact, name, email, openContactDetail, openCreateContact])
|
||||||
|
|
||||||
const openFromLongPress = useCallback(() => {
|
const openFromLongPress = useCallback(() => {
|
||||||
allowHoverOpenRef.current = true
|
allowHoverOpenRef.current = true
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
@ -164,7 +191,13 @@ export function ContactHoverCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="absolute right-0 top-0 h-8 w-8 shrink-0 text-[#5f6368] hover:bg-[#f1f3f4]"
|
className="absolute right-0 top-0 h-8 w-8 shrink-0 text-[#5f6368] hover:bg-[#f1f3f4]"
|
||||||
aria-label="Ajouter aux contacts"
|
aria-label={
|
||||||
|
matchedContact ? "Voir le contact" : "Ajouter aux contacts"
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openContactsPanel()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<UserPlus className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
<UserPlus className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -213,9 +246,22 @@ export function ContactHoverCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#f1f3f4] px-3 py-2.5 text-sm font-medium text-[#1a73e8] transition-colors hover:bg-[#e8eaed]"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#f1f3f4] px-3 py-2.5 text-sm font-medium text-[#1a73e8] transition-colors hover:bg-[#e8eaed]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openContactsPanel()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Ouvrir la vue détaillée
|
{matchedContact ? (
|
||||||
<ExternalLink className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
<>
|
||||||
|
Ouvrir la vue détaillée
|
||||||
|
<ExternalLink className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Ajouter aux contacts
|
||||||
|
<UserPlus className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
|
|||||||
323
components/gmail/contacts/contact-detail-view.tsx
Normal file
323
components/gmail/contacts/contact-detail-view.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
X,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Building2,
|
||||||
|
MapPin,
|
||||||
|
Cake,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
|
import { emails as allEmails } from "@/lib/email-data"
|
||||||
|
import { useComposeActions } from "@/lib/compose-context"
|
||||||
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
|
|
||||||
|
interface ContactDetailViewProps {
|
||||||
|
contactId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRENCH_MONTHS = [
|
||||||
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatBirthday(b: { day?: number; month?: number; year?: number }): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (b.day) parts.push(String(b.day))
|
||||||
|
if (b.month) parts.push(FRENCH_MONTHS[b.month - 1] ?? "")
|
||||||
|
if (b.year) parts.push(String(b.year))
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEmailDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.floor((now.getTime() - d.getTime()) / 86_400_000)
|
||||||
|
if (diffDays === 0) return "Aujourd'hui"
|
||||||
|
if (diffDays === 1) return "Hier"
|
||||||
|
if (diffDays < 7) return `Il y a ${diffDays} jours`
|
||||||
|
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
||||||
|
const { contacts, setView, closePanel } = useContactsStore()
|
||||||
|
const { openComposeWithInitial } = useComposeActions()
|
||||||
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
|
||||||
|
const contact = contacts.find((c) => c.id === contactId)
|
||||||
|
|
||||||
|
const recentInteractions = useMemo(() => {
|
||||||
|
if (!contact) return []
|
||||||
|
const contactEmails = new Set(
|
||||||
|
contact.emails.map((e) => e.value.toLowerCase()).filter(Boolean)
|
||||||
|
)
|
||||||
|
if (contactEmails.size === 0) return []
|
||||||
|
|
||||||
|
return allEmails
|
||||||
|
.filter((email) => {
|
||||||
|
const se = email.senderEmail?.toLowerCase()
|
||||||
|
if (se && contactEmails.has(se)) return true
|
||||||
|
const senderLower = email.sender.toLowerCase()
|
||||||
|
return [...contactEmails].some((ce) => senderLower.includes(ce.split("@")[0] ?? ""))
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.slice(0, 10)
|
||||||
|
}, [contact])
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
||||||
|
Contact introuvable
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = fullContactDisplayName(contact)
|
||||||
|
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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-w-0 flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={() => setView("list")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={() => setView("edit", contactId)}
|
||||||
|
aria-label="Modifier"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-400"
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={closePanel}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden [&_[data-slot=scroll-area-viewport]>div]:!block [&_[data-slot=scroll-area-viewport]>div]:min-w-0 [&_[data-slot=scroll-area-viewport]>div]:max-w-full">
|
||||||
|
<div className="w-full min-w-0 max-w-full overflow-x-hidden">
|
||||||
|
{/* Avatar + Name */}
|
||||||
|
<div className="flex flex-col items-center px-4 pt-6 pb-4">
|
||||||
|
{contact.avatarUrl ? (
|
||||||
|
<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="mt-3 max-w-full truncate px-2 text-center text-lg font-medium text-gray-900">
|
||||||
|
{name}
|
||||||
|
</h2>
|
||||||
|
{contact.company && (
|
||||||
|
<p className="max-w-full truncate px-2 text-center text-sm text-gray-500">
|
||||||
|
{contact.jobTitle ? `${contact.jobTitle} — ` : ""}
|
||||||
|
{contact.company}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{contact.labels && contact.labels.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{contact.labels.map((labelId) => {
|
||||||
|
const row = labelRows.find((r) => r.id === labelId)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={labelId}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{row && (
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
||||||
|
)}
|
||||||
|
{row?.label ?? labelId}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
{primaryEmail && (
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2 px-4 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-full bg-[#d3e3fd] px-5 text-sm font-medium text-[#001d35] transition-colors hover:bg-[#c4d9fc]"
|
||||||
|
onClick={() =>
|
||||||
|
openComposeWithInitial({
|
||||||
|
to: [{ name: displayName, email: primaryEmail }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Envoyer un e-mail
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact details */}
|
||||||
|
<div className="min-w-0 border-t border-gray-100">
|
||||||
|
{contact.emails.length > 0 && (
|
||||||
|
<DetailSection icon={<Mail className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
{contact.emails.map((e, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<p className="truncate text-sm text-[#1a73e8]">{e.value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{e.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contact.phones.length > 0 && (
|
||||||
|
<DetailSection icon={<Phone className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
{contact.phones.map((p, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<p className="text-sm text-[#1a73e8]">{p.value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{p.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contact.company && (
|
||||||
|
<DetailSection icon={<Building2 className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-900">{contact.company}</p>
|
||||||
|
{contact.department && (
|
||||||
|
<p className="text-xs text-gray-500">{contact.department}</p>
|
||||||
|
)}
|
||||||
|
{contact.jobTitle && (
|
||||||
|
<p className="text-xs text-gray-500">{contact.jobTitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contact.addresses && contact.addresses.length > 0 && (
|
||||||
|
<DetailSection icon={<MapPin className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
{contact.addresses.map((addr, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<p className="break-words text-sm text-gray-900 [overflow-wrap:anywhere]">
|
||||||
|
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{addr.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contact.birthday && (contact.birthday.day || contact.birthday.month) && (
|
||||||
|
<DetailSection icon={<Cake className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
<p className="text-sm text-gray-900">{formatBirthday(contact.birthday)}</p>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contact.notes && (
|
||||||
|
<DetailSection icon={<FileText className="h-4.5 w-4.5 text-gray-400" />}>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{contact.notes}</p>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent interactions */}
|
||||||
|
{recentInteractions.length > 0 && (
|
||||||
|
<div className="min-w-0 overflow-hidden border-t border-gray-100 pt-3 pb-4">
|
||||||
|
<h3 className="px-4 pb-2 text-xs font-medium uppercase text-gray-500">
|
||||||
|
Interactions récentes
|
||||||
|
</h3>
|
||||||
|
{recentInteractions.map((email) => (
|
||||||
|
<div
|
||||||
|
key={email.id}
|
||||||
|
className="flex min-w-0 gap-3 overflow-hidden px-4 py-2 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Mail className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<p className="truncate text-sm text-gray-900">{email.subject}</p>
|
||||||
|
<p className="line-clamp-2 break-words [overflow-wrap:anywhere] text-xs text-gray-500">
|
||||||
|
{email.preview}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-400">{formatEmailDate(email.date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSection({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 gap-3 px-4 py-3">
|
||||||
|
<div className="flex w-5 shrink-0 pt-0.5">{icon}</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2 overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
886
components/gmail/contacts/contact-form-view.tsx
Normal file
886
components/gmail/contacts/contact-form-view.tsx
Normal file
@ -0,0 +1,886 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
} from "react"
|
||||||
|
import { useForm, useFieldArray, Controller } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Star,
|
||||||
|
X,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Cake,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
|
|
||||||
|
const FRENCH_MONTHS = [
|
||||||
|
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||||
|
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const EMAIL_LABELS = ["Domicile", "Travail", "Autre"] as const
|
||||||
|
const PHONE_LABELS = ["Mobile", "Domicile", "Travail"] as const
|
||||||
|
const ADDRESS_LABELS = ["Domicile", "Travail", "Autre"] as const
|
||||||
|
|
||||||
|
const addressSchema = z.object({
|
||||||
|
street: z.string().optional().default(""),
|
||||||
|
city: z.string().optional().default(""),
|
||||||
|
region: z.string().optional().default(""),
|
||||||
|
postalCode: z.string().optional().default(""),
|
||||||
|
country: z.string().optional().default(""),
|
||||||
|
label: z.string().default("Domicile"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactFormSchema = z.object({
|
||||||
|
namePrefix: z.string().optional().default(""),
|
||||||
|
firstName: z.string().optional().default(""),
|
||||||
|
middleName: z.string().optional().default(""),
|
||||||
|
lastName: z.string().optional().default(""),
|
||||||
|
nameSuffix: z.string().optional().default(""),
|
||||||
|
phoneticFirstName: z.string().optional().default(""),
|
||||||
|
phoneticLastName: z.string().optional().default(""),
|
||||||
|
company: z.string().optional().default(""),
|
||||||
|
department: z.string().optional().default(""),
|
||||||
|
jobTitle: z.string().optional().default(""),
|
||||||
|
emails: z.array(
|
||||||
|
z.object({
|
||||||
|
value: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
phones: z.array(
|
||||||
|
z.object({
|
||||||
|
value: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
addresses: z.array(addressSchema),
|
||||||
|
birthday: z
|
||||||
|
.object({
|
||||||
|
day: z.any().optional(),
|
||||||
|
month: z.any().optional(),
|
||||||
|
year: z.any().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
notes: z.string().optional().default(""),
|
||||||
|
labels: z.array(z.string()).optional().default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ContactFormValues = z.infer<typeof contactFormSchema>
|
||||||
|
|
||||||
|
interface ContactFormViewProps {
|
||||||
|
mode: "create" | "edit"
|
||||||
|
contactId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||||
|
const {
|
||||||
|
contacts,
|
||||||
|
addContact,
|
||||||
|
updateContact,
|
||||||
|
setView,
|
||||||
|
closePanel,
|
||||||
|
createDraft,
|
||||||
|
clearCreateDraft,
|
||||||
|
} = useContactsStore()
|
||||||
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
|
const [starred, setStarred] = useState(false)
|
||||||
|
const [nameExpanded, setNameExpanded] = useState(false)
|
||||||
|
const [companyExpanded, setCompanyExpanded] = useState(false)
|
||||||
|
|
||||||
|
const existingContact =
|
||||||
|
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = useForm<ContactFormValues>({
|
||||||
|
resolver: zodResolver(contactFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
namePrefix: "",
|
||||||
|
firstName: "",
|
||||||
|
middleName: "",
|
||||||
|
lastName: "",
|
||||||
|
nameSuffix: "",
|
||||||
|
phoneticFirstName: "",
|
||||||
|
phoneticLastName: "",
|
||||||
|
company: "",
|
||||||
|
department: "",
|
||||||
|
jobTitle: "",
|
||||||
|
emails: [{ value: "", label: "Domicile" }],
|
||||||
|
phones: [{ value: "", label: "Mobile" }],
|
||||||
|
addresses: [],
|
||||||
|
birthday: { day: undefined, month: undefined, year: undefined },
|
||||||
|
notes: "",
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: emailFields,
|
||||||
|
append: appendEmail,
|
||||||
|
remove: removeEmail,
|
||||||
|
} = useFieldArray({ control, name: "emails" })
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: phoneFields,
|
||||||
|
append: appendPhone,
|
||||||
|
remove: removePhone,
|
||||||
|
} = useFieldArray({ control, name: "phones" })
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: addressFields,
|
||||||
|
append: appendAddress,
|
||||||
|
remove: removeAddress,
|
||||||
|
} = useFieldArray({ control, name: "addresses" })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "create" || !createDraft) return
|
||||||
|
reset({
|
||||||
|
namePrefix: "",
|
||||||
|
firstName: createDraft.firstName ?? "",
|
||||||
|
middleName: "",
|
||||||
|
lastName: createDraft.lastName ?? "",
|
||||||
|
nameSuffix: "",
|
||||||
|
phoneticFirstName: "",
|
||||||
|
phoneticLastName: "",
|
||||||
|
company: "",
|
||||||
|
department: "",
|
||||||
|
jobTitle: "",
|
||||||
|
emails: createDraft.emails?.length
|
||||||
|
? createDraft.emails
|
||||||
|
: [{ value: "", label: "Domicile" }],
|
||||||
|
phones: [{ value: "", label: "Mobile" }],
|
||||||
|
addresses: [],
|
||||||
|
birthday: { day: undefined, month: undefined, year: undefined },
|
||||||
|
notes: "",
|
||||||
|
labels: [],
|
||||||
|
}, { shouldDirty: true })
|
||||||
|
clearCreateDraft()
|
||||||
|
}, [mode, createDraft, reset, clearCreateDraft])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingContact) {
|
||||||
|
const hasExtendedName = !!(
|
||||||
|
existingContact.namePrefix ||
|
||||||
|
existingContact.middleName ||
|
||||||
|
existingContact.nameSuffix ||
|
||||||
|
existingContact.phoneticFirstName ||
|
||||||
|
existingContact.phoneticLastName
|
||||||
|
)
|
||||||
|
if (hasExtendedName) setNameExpanded(true)
|
||||||
|
if (existingContact.department) setCompanyExpanded(true)
|
||||||
|
|
||||||
|
reset({
|
||||||
|
namePrefix: existingContact.namePrefix ?? "",
|
||||||
|
firstName: existingContact.firstName,
|
||||||
|
middleName: existingContact.middleName ?? "",
|
||||||
|
lastName: existingContact.lastName,
|
||||||
|
nameSuffix: existingContact.nameSuffix ?? "",
|
||||||
|
phoneticFirstName: existingContact.phoneticFirstName ?? "",
|
||||||
|
phoneticLastName: existingContact.phoneticLastName ?? "",
|
||||||
|
company: existingContact.company ?? "",
|
||||||
|
department: existingContact.department ?? "",
|
||||||
|
jobTitle: existingContact.jobTitle ?? "",
|
||||||
|
emails: existingContact.emails.length
|
||||||
|
? existingContact.emails
|
||||||
|
: [{ value: "", label: "Domicile" }],
|
||||||
|
phones: existingContact.phones.length
|
||||||
|
? existingContact.phones
|
||||||
|
: [{ value: "", label: "Mobile" }],
|
||||||
|
addresses: existingContact.addresses ?? [],
|
||||||
|
birthday: existingContact.birthday ?? {
|
||||||
|
day: undefined,
|
||||||
|
month: undefined,
|
||||||
|
year: undefined,
|
||||||
|
},
|
||||||
|
notes: existingContact.notes ?? "",
|
||||||
|
labels: existingContact.labels ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [existingContact, reset])
|
||||||
|
|
||||||
|
const firstName = watch("firstName")
|
||||||
|
const lastName = watch("lastName")
|
||||||
|
const watchedEmails = watch("emails")
|
||||||
|
const currentLabels = watch("labels") ?? []
|
||||||
|
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||||
|
|
||||||
|
const canSave =
|
||||||
|
isDirty ||
|
||||||
|
(mode === "create" &&
|
||||||
|
!!(
|
||||||
|
firstName?.trim() ||
|
||||||
|
lastName?.trim() ||
|
||||||
|
watchedEmails?.some((e) => e.value?.trim())
|
||||||
|
))
|
||||||
|
|
||||||
|
const toggleLabel = useCallback(
|
||||||
|
(labelId: string) => {
|
||||||
|
const next = currentLabels.includes(labelId)
|
||||||
|
? currentLabels.filter((l) => l !== labelId)
|
||||||
|
: [...currentLabels, labelId]
|
||||||
|
setValue("labels", next, { shouldDirty: true })
|
||||||
|
},
|
||||||
|
[currentLabels, setValue],
|
||||||
|
)
|
||||||
|
|
||||||
|
function onSubmit(data: ContactFormValues) {
|
||||||
|
const payload = {
|
||||||
|
namePrefix: data.namePrefix || undefined,
|
||||||
|
firstName: data.firstName ?? "",
|
||||||
|
middleName: data.middleName || undefined,
|
||||||
|
lastName: data.lastName ?? "",
|
||||||
|
nameSuffix: data.nameSuffix || undefined,
|
||||||
|
phoneticFirstName: data.phoneticFirstName || undefined,
|
||||||
|
phoneticLastName: data.phoneticLastName || undefined,
|
||||||
|
company: data.company || undefined,
|
||||||
|
department: data.department || undefined,
|
||||||
|
jobTitle: data.jobTitle || undefined,
|
||||||
|
emails: data.emails.filter((e) => e.value),
|
||||||
|
phones: data.phones.filter((p) => p.value),
|
||||||
|
addresses: data.addresses.filter(
|
||||||
|
(a) => a.street || a.city || a.region || a.postalCode || a.country,
|
||||||
|
),
|
||||||
|
birthday:
|
||||||
|
data.birthday?.day || data.birthday?.month || data.birthday?.year
|
||||||
|
? data.birthday
|
||||||
|
: undefined,
|
||||||
|
notes: data.notes || undefined,
|
||||||
|
labels: data.labels?.length ? data.labels : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
const id = addContact(payload)
|
||||||
|
setView("view", id)
|
||||||
|
} else if (contactId) {
|
||||||
|
updateContact(contactId, payload)
|
||||||
|
setView("view", contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableLabels = labelRows.filter((r) => r.enabled !== false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={() =>
|
||||||
|
mode === "edit" && contactId
|
||||||
|
? setView("view", contactId)
|
||||||
|
: setView("list")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => setStarred((s) => !s)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`h-4 w-4 ${starred ? "fill-yellow-400 text-yellow-400" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSave}
|
||||||
|
className="rounded-full bg-[#f1f3f4] px-5 h-9 text-sm font-medium text-[#3c4043] hover:bg-[#e8eaed] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={closePanel}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex flex-col items-center py-6">
|
||||||
|
{displayName ? (
|
||||||
|
<div
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
|
||||||
|
style={{ backgroundColor: avatarColor(displayName) }}
|
||||||
|
>
|
||||||
|
{senderInitial(displayName)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-gray-500">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-1.5 px-4 pb-4">
|
||||||
|
{currentLabels.map((labelId) => {
|
||||||
|
const row = labelRows.find((r) => r.id === labelId)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={labelId}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-700"
|
||||||
|
>
|
||||||
|
{row && (
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
||||||
|
)}
|
||||||
|
{row?.label ?? labelId}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLabel(labelId)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-gray-300 px-2.5 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Libellé
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-52 p-1" align="center">
|
||||||
|
<p className="px-2 py-1.5 text-xs font-medium text-gray-500">
|
||||||
|
Libellés
|
||||||
|
</p>
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{availableLabels.map((row) => {
|
||||||
|
const active = currentLabels.includes(row.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLabel(row.id)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
|
||||||
|
<span className="flex-1 truncate">{row.label}</span>
|
||||||
|
{active && <Check className="h-3.5 w-3.5 text-blue-600" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name section */}
|
||||||
|
<FormSection icon={<User className="h-5 w-5 text-gray-400" />}>
|
||||||
|
{nameExpanded && (
|
||||||
|
<FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FloatingInput label="Prénom" {...register("firstName")} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
|
||||||
|
onClick={() => setNameExpanded((e) => !e)}
|
||||||
|
>
|
||||||
|
{nameExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{nameExpanded && (
|
||||||
|
<FloatingInput label="Deuxième prénom" {...register("middleName")} />
|
||||||
|
)}
|
||||||
|
<FloatingInput label="Nom" {...register("lastName")} />
|
||||||
|
{nameExpanded && (
|
||||||
|
<>
|
||||||
|
<FloatingInput label="Suffixe (Jr., Sr...)" {...register("nameSuffix")} />
|
||||||
|
<FloatingInput label="Prénom phonétique" {...register("phoneticFirstName")} />
|
||||||
|
<FloatingInput label="Nom phonétique" {...register("phoneticLastName")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Company section */}
|
||||||
|
<FormSection icon={<Building2 className="h-5 w-5 text-gray-400" />}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FloatingInput label="Entreprise" {...register("company")} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
|
||||||
|
onClick={() => setCompanyExpanded((e) => !e)}
|
||||||
|
>
|
||||||
|
{companyExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{companyExpanded && (
|
||||||
|
<FloatingInput label="Service" {...register("department")} />
|
||||||
|
)}
|
||||||
|
<FloatingInput label="Fonction" {...register("jobTitle")} />
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Email section */}
|
||||||
|
<FormSection icon={<Mail className="h-5 w-5 text-gray-400" />}>
|
||||||
|
{emailFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FloatingInput
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
{...register(`emails.${index}.value`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailFields.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
|
||||||
|
onClick={() => removeEmail(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`emails.${index}.label`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<CompactSelect
|
||||||
|
value={f.value}
|
||||||
|
onValueChange={f.onChange}
|
||||||
|
options={EMAIL_LABELS.map((l) => ({ value: l, label: l }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton onClick={() => appendEmail({ value: "", label: "Domicile" })}>
|
||||||
|
Ajouter une adresse e-mail
|
||||||
|
</AddButton>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Phone section */}
|
||||||
|
<FormSection icon={<Phone className="h-5 w-5 text-gray-400" />}>
|
||||||
|
{phoneFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">
|
||||||
|
🇫🇷
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<FloatingInput
|
||||||
|
label="Téléphone"
|
||||||
|
type="tel"
|
||||||
|
{...register(`phones.${index}.value`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{phoneFields.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
|
||||||
|
onClick={() => removePhone(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`phones.${index}.label`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<CompactSelect
|
||||||
|
value={f.value}
|
||||||
|
onValueChange={f.onChange}
|
||||||
|
options={PHONE_LABELS.map((l) => ({ value: l, label: l }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton onClick={() => appendPhone({ value: "", label: "Mobile" })}>
|
||||||
|
Ajouter un numéro de téléphone
|
||||||
|
</AddButton>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Address section */}
|
||||||
|
<FormSection icon={<MapPin className="h-5 w-5 text-gray-400" />}>
|
||||||
|
{addressFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="space-y-2 rounded-lg border border-gray-200 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`addresses.${index}.label`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<CompactSelect
|
||||||
|
value={f.value}
|
||||||
|
onValueChange={f.onChange}
|
||||||
|
options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 rounded-full text-gray-400"
|
||||||
|
onClick={() => removeAddress(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FloatingInput label="Rue" {...register(`addresses.${index}.street`)} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-24">
|
||||||
|
<FloatingInput label="Code postal" {...register(`addresses.${index}.postalCode`)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<FloatingInput label="Ville" {...register(`addresses.${index}.city`)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FloatingInput label="Région / Province" {...register(`addresses.${index}.region`)} />
|
||||||
|
<FloatingInput label="Pays" {...register(`addresses.${index}.country`)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton
|
||||||
|
onClick={() =>
|
||||||
|
appendAddress({
|
||||||
|
street: "",
|
||||||
|
city: "",
|
||||||
|
region: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "",
|
||||||
|
label: "Domicile",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ajouter une adresse
|
||||||
|
</AddButton>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Birthday section */}
|
||||||
|
<FormSection icon={<Cake className="h-5 w-5 text-gray-400" />}>
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<div className="w-[72px]">
|
||||||
|
<FloatingInput
|
||||||
|
label="Jour"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
{...register("birthday.day", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="birthday.month"
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<CompactSelect
|
||||||
|
value={f.value ? String(f.value) : ""}
|
||||||
|
onValueChange={(v) => f.onChange(v ? Number(v) : undefined)}
|
||||||
|
options={FRENCH_MONTHS.map((name, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: name,
|
||||||
|
}))}
|
||||||
|
placeholder="Mois"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<FloatingInput
|
||||||
|
label="Année"
|
||||||
|
type="number"
|
||||||
|
min={1900}
|
||||||
|
max={2100}
|
||||||
|
{...register("birthday.year", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Notes section */}
|
||||||
|
<FormSection icon={<FileText className="h-5 w-5 text-gray-400" />}>
|
||||||
|
<FloatingTextarea label="Notes" {...register("notes")} />
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<div className="h-8" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Layout helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function FormSection({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 px-4 py-2">
|
||||||
|
<div className="flex w-5 shrink-0 pt-2">{icon}</div>
|
||||||
|
<div className="flex-1 space-y-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddButton({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-2 py-1 text-sm text-[#1a73e8] hover:text-[#1557b0]"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Floating label input (Material outlined style) ─────── */
|
||||||
|
|
||||||
|
interface FloatingInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
|
||||||
|
function FloatingInput({ label, className, defaultValue, ...props }, ref) {
|
||||||
|
const id = useId()
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const [filled, setFilled] = useState(() => !!defaultValue)
|
||||||
|
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (innerRef.current && innerRef.current.value) setFilled(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node: HTMLInputElement | null) => {
|
||||||
|
innerRef.current = node
|
||||||
|
if (typeof ref === "function") ref(node)
|
||||||
|
else if (ref) ref.current = node
|
||||||
|
if (node && node.value) setFilled(true)
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
)
|
||||||
|
|
||||||
|
const floated = focused || filled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={setRefs}
|
||||||
|
id={id}
|
||||||
|
{...props}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
className={`peer h-[42px] w-full rounded border bg-white px-3 pt-4 pb-1 text-sm outline-none transition-colors ${
|
||||||
|
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
|
||||||
|
} ${className ?? ""}`}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setFocused(true)
|
||||||
|
props.onFocus?.(e)
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocused(false)
|
||||||
|
setFilled(!!e.target.value)
|
||||||
|
props.onBlur?.(e)
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilled(!!e.target.value)
|
||||||
|
props.onChange?.(e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${
|
||||||
|
floated
|
||||||
|
? "top-0.5 px-0.5 text-[10px] leading-tight"
|
||||||
|
: "top-[11px] text-sm"
|
||||||
|
} ${focused ? "text-blue-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ─── Floating label textarea ────────────────────────────── */
|
||||||
|
|
||||||
|
interface FloatingTextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
|
||||||
|
function FloatingTextarea({ label, className, ...props }, ref) {
|
||||||
|
const id = useId()
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const [filled, setFilled] = useState(false)
|
||||||
|
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (innerRef.current && innerRef.current.value) setFilled(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node: HTMLTextAreaElement | null) => {
|
||||||
|
innerRef.current = node
|
||||||
|
if (typeof ref === "function") ref(node)
|
||||||
|
else if (ref) ref.current = node
|
||||||
|
if (node && node.value) setFilled(true)
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
)
|
||||||
|
|
||||||
|
const floated = focused || filled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
ref={setRefs}
|
||||||
|
id={id}
|
||||||
|
rows={3}
|
||||||
|
{...props}
|
||||||
|
className={`peer w-full rounded border bg-white px-3 pt-5 pb-2 text-sm outline-none transition-colors resize-none ${
|
||||||
|
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
|
||||||
|
} ${className ?? ""}`}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setFocused(true)
|
||||||
|
props.onFocus?.(e)
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocused(false)
|
||||||
|
setFilled(!!e.target.value)
|
||||||
|
props.onBlur?.(e)
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilled(!!e.target.value)
|
||||||
|
props.onChange?.(e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${
|
||||||
|
floated
|
||||||
|
? "top-1 px-0.5 text-[10px] leading-tight"
|
||||||
|
: "top-2.5 text-sm"
|
||||||
|
} ${focused ? "text-blue-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ─── Compact select (no floating label, just small) ─────── */
|
||||||
|
|
||||||
|
function CompactSelect({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onValueChange: (v: string) => void
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onValueChange}>
|
||||||
|
<SelectTrigger className="!h-[42px] !min-h-[42px] w-full rounded border border-gray-300 bg-white px-3 py-0 text-sm shadow-none data-[size=default]:!h-[42px] focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||||
|
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
components/gmail/contacts/contact-row.tsx
Normal file
46
components/gmail/contacts/contact-row.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
|
|
||||||
|
interface ContactRowProps {
|
||||||
|
contact: FullContact
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactRow({ contact, onClick }: ContactRowProps) {
|
||||||
|
const displayName = fullContactDisplayName(contact)
|
||||||
|
const name = displayName || 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center gap-3 px-4 h-14 hover:bg-gray-50 cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
{contact.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={contact.avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
className="h-10 w-10 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white font-medium text-sm"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-gray-900">{name}</div>
|
||||||
|
{subtitle && displayName && (
|
||||||
|
<div className="truncate text-xs text-gray-500">{subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
components/gmail/contacts/contacts-list-view.tsx
Normal file
173
components/gmail/contacts/contacts-list-view.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRef, useEffect, useMemo } from "react"
|
||||||
|
import { Search, ExternalLink, X, Plus } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
||||||
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
|
import { ContactRow } from "./contact-row"
|
||||||
|
|
||||||
|
export function ContactsListView() {
|
||||||
|
const {
|
||||||
|
contacts,
|
||||||
|
searchMode,
|
||||||
|
searchQuery,
|
||||||
|
setSearchMode,
|
||||||
|
setSearchQuery,
|
||||||
|
setView,
|
||||||
|
closePanel,
|
||||||
|
} = useContactsStore()
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchMode) {
|
||||||
|
searchInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [searchMode])
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
if (searchMode && searchQuery) {
|
||||||
|
return searchContacts(contacts, searchQuery)
|
||||||
|
}
|
||||||
|
return contacts
|
||||||
|
}, [contacts, searchMode, searchQuery])
|
||||||
|
|
||||||
|
const groupedContacts = useMemo(() => {
|
||||||
|
const sorted = [...filteredContacts].sort((a, b) => {
|
||||||
|
const nameA = fullContactDisplayName(a) || a.emails[0]?.value || ""
|
||||||
|
const nameB = fullContactDisplayName(b) || b.emails[0]?.value || ""
|
||||||
|
return nameA.localeCompare(nameB, "fr")
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalize = (ch: string) =>
|
||||||
|
ch.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase() || "?"
|
||||||
|
|
||||||
|
const groups: { letter: string; items: typeof sorted }[] = []
|
||||||
|
for (const contact of sorted) {
|
||||||
|
const name = fullContactDisplayName(contact) || contact.emails[0]?.value || "?"
|
||||||
|
const letter = normalize(name.charAt(0))
|
||||||
|
const last = groups[groups.length - 1]
|
||||||
|
if (last && last.letter === letter) {
|
||||||
|
last.items.push(contact)
|
||||||
|
} else {
|
||||||
|
groups.push({ letter, items: [contact] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}, [filteredContacts])
|
||||||
|
|
||||||
|
function exitSearch() {
|
||||||
|
setSearchQuery("")
|
||||||
|
setSearchMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-gray-200 px-4">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Recherche..."
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-full"
|
||||||
|
onClick={exitSearch}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<CreateContactButton onClick={() => setView("create")} />
|
||||||
|
{filteredContacts.map((contact) => (
|
||||||
|
<ContactRow
|
||||||
|
key={contact.id}
|
||||||
|
contact={contact}
|
||||||
|
onClick={() => setView("view", contact.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-4">
|
||||||
|
<span className="text-lg font-medium text-gray-900">Contacts</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={() => setSearchMode(true)}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href="https://contacts.google.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full text-gray-600"
|
||||||
|
onClick={closePanel}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<CreateContactButton onClick={() => setView("create")} />
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-gray-500">
|
||||||
|
Contacts ({contacts.length})
|
||||||
|
</div>
|
||||||
|
{groupedContacts.map((group) => (
|
||||||
|
<div key={group.letter}>
|
||||||
|
<div className="px-4 py-1 text-xs font-medium uppercase text-gray-500">
|
||||||
|
{group.letter}
|
||||||
|
</div>
|
||||||
|
{group.items.map((contact) => (
|
||||||
|
<ContactRow
|
||||||
|
key={contact.id}
|
||||||
|
contact={contact}
|
||||||
|
onClick={() => setView("view", contact.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateContactButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center gap-3 px-4 h-12 hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center">
|
||||||
|
<Plus className="h-5 w-5 text-[#1a73e8]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-[#1a73e8]">Créer un contact</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
components/gmail/contacts/contacts-panel.tsx
Normal file
50
components/gmail/contacts/contacts-panel.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from "react"
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { ContactsListView } from "./contacts-list-view"
|
||||||
|
import { ContactFormView } from "./contact-form-view"
|
||||||
|
import { ContactDetailView } from "./contact-detail-view"
|
||||||
|
|
||||||
|
export function ContactsPanel() {
|
||||||
|
const { panelOpen, view, activeContactId, closePanel, setSearchMode, setSearchQuery, searchMode } =
|
||||||
|
useContactsStore()
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!panelOpen || view !== "list" || searchMode) return
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return
|
||||||
|
if (e.key.length === 1 && /\S/.test(e.key)) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearchMode(true)
|
||||||
|
setSearchQuery(e.key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panelOpen, view, searchMode, setSearchMode, setSearchQuery],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={panelOpen} onOpenChange={(open) => !open && closePanel()}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
hideClose
|
||||||
|
overlayClassName="bg-transparent"
|
||||||
|
className="w-[360px] sm:max-w-[360px] p-0 gap-0"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">Contacts</SheetTitle>
|
||||||
|
{view === "list" && <ContactsListView />}
|
||||||
|
{view === "view" && <ContactDetailView contactId={activeContactId} />}
|
||||||
|
{view === "create" && <ContactFormView mode="create" />}
|
||||||
|
{view === "edit" && <ContactFormView mode="edit" contactId={activeContactId} />}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
components/gmail/contacts/index.ts
Normal file
1
components/gmail/contacts/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ContactsPanel } from "./contacts-panel"
|
||||||
@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
import { Calendar, Users, CheckSquare, Plus } from "lucide-react"
|
import { Calendar, Users, CheckSquare, Plus } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function RightPanel() {
|
export function RightPanel() {
|
||||||
|
const { panelOpen, togglePanel } = useContactsStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
|
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
||||||
@ -12,7 +16,16 @@ export function RightPanel() {
|
|||||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
||||||
<CheckSquare className="h-4 w-4" />
|
<CheckSquare className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 rounded-full",
|
||||||
|
panelOpen ? "bg-blue-100 text-[#1a73e8]" : "text-gray-600"
|
||||||
|
)}
|
||||||
|
onClick={togglePanel}
|
||||||
|
aria-label="Contacts"
|
||||||
|
>
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
142
lib/contacts/contacts-store.ts
Normal file
142
lib/contacts/contacts-store.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { persist } from "zustand/middleware"
|
||||||
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||||
|
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 }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsState {
|
||||||
|
contacts: FullContact[]
|
||||||
|
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
|
||||||
|
updateContact: (id: string, patch: Partial<FullContact>) => void
|
||||||
|
deleteContact: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContactsStore = ContactsState & ContactsActions
|
||||||
|
|
||||||
|
export const useContactsStore = create<ContactsStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
contacts: MOCK_FULL_CONTACTS,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "contacts-store",
|
||||||
|
storage: debouncedPersistJSONStorage,
|
||||||
|
partialize: (state) => ({ contacts: state.contacts }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
31
lib/contacts/find-contact.ts
Normal file
31
lib/contacts/find-contact.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { FullContact } from "./types"
|
||||||
|
|
||||||
|
export function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findContactByEmail(
|
||||||
|
contacts: FullContact[],
|
||||||
|
email: string,
|
||||||
|
): FullContact | undefined {
|
||||||
|
const norm = normalizeEmail(email)
|
||||||
|
if (!norm) return undefined
|
||||||
|
return contacts.find((c) =>
|
||||||
|
c.emails.some((e) => normalizeEmail(e.value) === norm),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split display name into first / last for create form prefill. */
|
||||||
|
export function parseDisplayNameToNameParts(displayName: string): {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
} {
|
||||||
|
const clean = displayName.trim()
|
||||||
|
if (!clean) return { firstName: "", lastName: "" }
|
||||||
|
const space = clean.indexOf(" ")
|
||||||
|
if (space === -1) return { firstName: clean, lastName: "" }
|
||||||
|
return {
|
||||||
|
firstName: clean.slice(0, space),
|
||||||
|
lastName: clean.slice(space + 1).trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
64
lib/contacts/fuzzy-search.ts
Normal file
64
lib/contacts/fuzzy-search.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import Fuse, { type IFuseOptions } from "fuse.js"
|
||||||
|
import type { FullContact } from "./types"
|
||||||
|
|
||||||
|
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(
|
||||||
|
contacts: FullContact[],
|
||||||
|
query: string
|
||||||
|
): FullContact[] {
|
||||||
|
if (!query.trim()) return contacts
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
10
lib/contacts/index.ts
Normal file
10
lib/contacts/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { type FullContact, fullContactDisplayName, toComposeContact } from "./types"
|
||||||
|
export { MOCK_FULL_CONTACTS } from "./mock-data"
|
||||||
|
export { useContactsStore, type ContactsStore } from "./contacts-store"
|
||||||
|
export { searchContacts } from "./fuzzy-search"
|
||||||
|
export {
|
||||||
|
findContactByEmail,
|
||||||
|
normalizeEmail,
|
||||||
|
parseDisplayNameToNameParts,
|
||||||
|
} from "./find-contact"
|
||||||
|
export type { ContactCreateDraft } from "./contacts-store"
|
||||||
170
lib/contacts/mock-data.ts
Normal file
170
lib/contacts/mock-data.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import type { FullContact } from "./types"
|
||||||
|
|
||||||
|
let _id = 0
|
||||||
|
function nextId(): string {
|
||||||
|
return `contact-${String(++_id).padStart(3, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = 1716000000000
|
||||||
|
|
||||||
|
function c(
|
||||||
|
partial: Omit<FullContact, "id" | "createdAt" | "updatedAt"> & {
|
||||||
|
createdAt?: number
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
): FullContact {
|
||||||
|
return {
|
||||||
|
id: nextId(),
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
...partial,
|
||||||
|
} as FullContact
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_FULL_CONTACTS: FullContact[] = [
|
||||||
|
c({ firstName: "", lastName: "", phones: [{ value: "+216 91 108 533", label: "mobile" }], emails: [] }),
|
||||||
|
c({ firstName: "Agence", lastName: "?", emails: [{ value: "contact@agence-question.fr", label: "work" }], phones: [] }),
|
||||||
|
c({ firstName: "Adama", lastName: "Diallo", emails: [{ value: "adama.diallo@outlook.com", label: "personal" }], phones: [{ value: "+33 6 12 34 56 78", label: "mobile" }], company: "Senegal Tech", jobTitle: "Backend Engineer" }),
|
||||||
|
c({ firstName: "Adrien", lastName: "Moreau", emails: [{ value: "adrien.moreau@gmail.com", label: "personal" }], phones: [], birthday: { day: 14, month: 3, year: 1992 } }),
|
||||||
|
c({ firstName: "Aïcha", lastName: "Benmoussa", emails: [{ value: "aicha.benmoussa@yahoo.fr", label: "personal" }], phones: [{ value: "+212 6 61 23 45 67", label: "mobile" }], company: "Casablanca Media" }),
|
||||||
|
c({ firstName: "Alexandre", lastName: "Netchaev", emails: [{ value: "alex.netchaev@gmail.com", label: "personal" }], phones: [{ value: "+33 6 45 67 89 01", label: "mobile" }], nicknames: ["Alex"] }),
|
||||||
|
c({ firstName: "Alix", lastName: "Macgregor", emails: [{ value: "alix.macgregor@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Alizée", lastName: "Film", emails: [{ value: "alizee@alizeefilm.com", label: "work" }], phones: [{ value: "+33 1 42 33 44 55", label: "work" }], company: "Alizée Film", jobTitle: "Producer" }),
|
||||||
|
c({ firstName: "Amadou", lastName: "Traoré", emails: [{ value: "amadou.traore@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Anaïs", lastName: "SATIS", emails: [{ value: "anais.satis@gmail.com", label: "personal" }], phones: [{ value: "+33 6 78 90 12 34", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Antoine", lastName: "Le Squerent", emails: [{ value: "antoine.lesquerent@gmail.com", label: "personal" }], phones: [], nicknames: ["Anto"] }),
|
||||||
|
c({ firstName: "Armelle", lastName: "Loste", emails: [{ value: "armelle.loste@outlook.fr", label: "personal" }], phones: [{ value: "+33 6 11 22 33 44", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Arthur", lastName: "Cohen", emails: [{ value: "arthur.cohen@gmail.com", label: "personal" }], phones: [], company: "Freelance", jobTitle: "Motion Designer" }),
|
||||||
|
c({ firstName: "Augustin", lastName: "Ferrand", emails: [{ value: "augustin.ferrand@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Aurélien", lastName: "Petit", emails: [{ value: "aurelien.petit@gmail.com", label: "personal" }], phones: [{ value: "+33 6 99 88 77 66", label: "mobile" }], birthday: { day: 22, month: 8, year: 1988 } }),
|
||||||
|
c({ firstName: "Baptiste", lastName: "Roux", emails: [{ value: "baptiste.roux@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Benjamin", lastName: "Budd", emails: [{ value: "benjamin.budd@gmail.com", label: "personal" }], phones: [{ value: "+44 7700 900123", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Billionaire", lastName: "Donald", emails: [{ value: "billionaire.donald@outlook.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Bruno", lastName: "RAFFY", emails: [{ value: "bruno.raffy@bpifrance.fr", label: "work" }], phones: [{ value: "+33 1 41 79 80 00", label: "work" }], company: "Bpifrance", jobTitle: "Investment Director" }),
|
||||||
|
c({ firstName: "Camille", lastName: "Durand", emails: [{ value: "camille.durand@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Cédric", lastName: "Nguyen", emails: [{ value: "cedric.nguyen@outlook.com", label: "personal" }], phones: [{ value: "+33 6 55 44 33 22", label: "mobile" }], company: "VietTech Paris", jobTitle: "CTO" }),
|
||||||
|
c({ firstName: "Charles", lastName: "Lefèvre", emails: [{ value: "charles.lefevre@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Charlotte", lastName: "Martin", emails: [{ value: "charlotte.martin@gmail.com", label: "personal" }], phones: [{ value: "+33 6 22 11 00 99", label: "mobile" }], birthday: { day: 5, month: 11, year: 1995 } }),
|
||||||
|
c({ firstName: "Chen", lastName: "Wei", emails: [{ value: "chen.wei@163.com", label: "personal" }], phones: [{ value: "+86 138 0013 8000", label: "mobile" }], company: "Huawei", jobTitle: "Software Architect" }),
|
||||||
|
c({ firstName: "Clara", lastName: "Fontaine", emails: [{ value: "clara.fontaine@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Clément", lastName: "Barbier", emails: [{ value: "clement.barbier@yahoo.fr", label: "personal" }], phones: [{ value: "+33 6 10 20 30 40", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Damien", lastName: "Girard", emails: [{ value: "damien.girard@gmail.com", label: "personal" }], phones: [], company: "Dassault Systèmes", jobTitle: "UX Lead" }),
|
||||||
|
c({ firstName: "David", lastName: "Okonkwo", emails: [{ value: "david.okonkwo@outlook.com", label: "personal" }], phones: [{ value: "+234 803 123 4567", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Delphine", lastName: "Vasseur", emails: [{ value: "delphine.vasseur@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Diana", lastName: "Popescu", emails: [{ value: "diana.popescu@yahoo.com", label: "personal" }], phones: [{ value: "+40 721 234 567", label: "mobile" }], company: "Bucharest Digital" }),
|
||||||
|
c({ firstName: "Djibril", lastName: "Sow", emails: [{ value: "djibril.sow@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Elena", lastName: "Kuznetsova", emails: [{ value: "elena.kuznetsova@mail.ru", label: "personal" }], phones: [{ value: "+7 916 123 45 67", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Eliott", lastName: "Guillaumin BL", emails: [{ value: "eliott@blacklight.tv", label: "work" }], phones: [{ value: "+33 6 29 25 16 98", label: "mobile" }], company: "Blacklight", jobTitle: "Technical Director", nicknames: ["Eli"] }),
|
||||||
|
c({ firstName: "Émilie", lastName: "Rousseau", emails: [{ value: "emilie.rousseau@gmail.com", label: "personal" }], phones: [{ value: "+33 6 88 77 66 55", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Emma", lastName: "Johansson", emails: [{ value: "emma.johansson@outlook.se", label: "personal" }], phones: [], company: "Spotify", jobTitle: "Product Manager" }),
|
||||||
|
c({ firstName: "Étienne", lastName: "Marchand", emails: [{ value: "etienne.marchand@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Fabien", lastName: "Leclerc", emails: [{ value: "fabien.leclerc@gmail.com", label: "personal" }], phones: [{ value: "+33 6 34 56 78 90", label: "mobile" }], birthday: { day: 1, month: 1, year: 1985 } }),
|
||||||
|
c({ firstName: "Fatima", lastName: "El Amrani", emails: [{ value: "fatima.elamrani@gmail.com", label: "personal" }], phones: [{ value: "+212 6 70 12 34 56", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Félix", lastName: "Dubois", emails: [{ value: "felix.dubois@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Florian", lastName: "Meyer", emails: [{ value: "florian.meyer@gmx.de", label: "personal" }], phones: [{ value: "+49 170 1234567", label: "mobile" }], company: "SAP", jobTitle: "DevOps Engineer" }),
|
||||||
|
c({ firstName: "François", lastName: "Bernard", emails: [{ value: "francois.bernard@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Gabriel", lastName: "Santos", emails: [{ value: "gabriel.santos@outlook.com", label: "personal" }], phones: [{ value: "+55 11 98765 4321", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Grégoire", lastName: "Lamy", emails: [{ value: "gregoire.lamy@gmail.com", label: "personal" }], phones: [], nicknames: ["Greg"] }),
|
||||||
|
c({ firstName: "Guillaume", lastName: "Perrot", emails: [{ value: "guillaume.perrot@proton.me", label: "personal" }], phones: [{ value: "+33 6 77 88 99 00", label: "mobile" }], company: "OVHcloud", jobTitle: "SRE" }),
|
||||||
|
c({ firstName: "Hana", lastName: "Yamamoto", emails: [{ value: "hana.yamamoto@gmail.com", label: "personal" }], phones: [{ value: "+81 90 1234 5678", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Hassan", lastName: "Kaddouri", emails: [{ value: "hassan.kaddouri@outlook.com", label: "personal" }], phones: [{ value: "+216 22 345 678", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Hugo", lastName: "Garnier", emails: [{ value: "hugo.garnier@gmail.com", label: "personal" }], phones: [], birthday: { day: 30, month: 6, year: 1990 } }),
|
||||||
|
c({ firstName: "Ibrahim", lastName: "Touré", emails: [{ value: "ibrahim.toure@yahoo.fr", label: "personal" }], phones: [{ value: "+225 07 08 09 10 11", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Inès", lastName: "Belhadj", emails: [{ value: "ines.belhadj@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Ismaël", lastName: "Diop", emails: [{ value: "ismael.diop@outlook.com", label: "personal" }], phones: [{ value: "+221 77 123 45 67", label: "mobile" }], company: "Dakar Startup Hub" }),
|
||||||
|
c({ firstName: "Jade", lastName: "Renard", emails: [{ value: "jade.renard@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Javier", lastName: "Muñoz", emails: [{ value: "javier.munoz@gmail.com", label: "personal" }], phones: [{ value: "+34 612 345 678", label: "mobile" }], company: "Telefónica", jobTitle: "Data Scientist" }),
|
||||||
|
c({ firstName: "Jean-Baptiste", lastName: "Vidal", emails: [{ value: "jb.vidal@gmail.com", label: "personal" }], phones: [{ value: "+33 6 44 55 66 77", label: "mobile" }], nicknames: ["JB"] }),
|
||||||
|
c({ firstName: "Julien", lastName: "Carpentier", emails: [{ value: "julien.carpentier@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Juliette", lastName: "Blanc", emails: [{ value: "juliette.blanc@gmail.com", label: "personal" }], phones: [{ value: "+33 6 00 11 22 33", label: "mobile" }], birthday: { day: 17, month: 9, year: 1993 } }),
|
||||||
|
c({ firstName: "Karim", lastName: "Benzema", emails: [{ value: "karim.b@outlook.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Kenza", lastName: "Amiri", emails: [{ value: "kenza.amiri@gmail.com", label: "personal" }], phones: [{ value: "+33 6 12 98 76 54", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Kevin", lastName: "Park", emails: [{ value: "kevin.park@gmail.com", label: "personal" }], phones: [{ value: "+82 10 1234 5678", label: "mobile" }], company: "Samsung", jobTitle: "Frontend Engineer" }),
|
||||||
|
c({ firstName: "Laëtitia", lastName: "Morin", emails: [{ value: "laetitia.morin@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Léa", lastName: "Fournier", emails: [{ value: "lea.fournier@gmail.com", label: "personal" }], phones: [{ value: "+33 6 33 44 55 66", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Léo", lastName: "Mercier", emails: [{ value: "leo.mercier@proton.me", label: "personal" }], phones: [], company: "Freelance", jobTitle: "Photographer" }),
|
||||||
|
c({ firstName: "Lina", lastName: "Cheng", emails: [{ value: "lina.cheng@outlook.com", label: "personal" }], phones: [{ value: "+86 139 8765 4321", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Louis", lastName: "Lambert", emails: [{ value: "louis.lambert@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Louise", lastName: "Chevalier", emails: [{ value: "louise.chevalier@yahoo.fr", label: "personal" }], phones: [{ value: "+33 6 56 78 90 12", label: "mobile" }], birthday: { day: 28, month: 2, year: 1997 } }),
|
||||||
|
c({ firstName: "Lucas", lastName: "Bonnet", emails: [{ value: "lucas.bonnet@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Lucie", lastName: "Simon", emails: [{ value: "lucie.simon@proton.me", label: "personal" }], phones: [{ value: "+33 6 67 89 01 23", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Malik", lastName: "Cissé", emails: [{ value: "malik.cisse@gmail.com", label: "personal" }], phones: [], company: "Ubisoft", jobTitle: "Game Designer" }),
|
||||||
|
c({ firstName: "Manon", lastName: "Leroy", emails: [{ value: "manon.leroy@outlook.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Marc", lastName: "Dupont", emails: [{ value: "marc.dupont@gmail.com", label: "personal" }], phones: [{ value: "+33 6 78 12 34 56", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Marie", lastName: "Deschamps", emails: [{ value: "marie.deschamps@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Mathieu", lastName: "Laurent", emails: [{ value: "mathieu.laurent@gmail.com", label: "personal" }], phones: [{ value: "+33 6 89 01 23 45", label: "mobile" }], company: "Capgemini", jobTitle: "Consultant" }),
|
||||||
|
c({ firstName: "Mathilde", lastName: "Robin", emails: [{ value: "mathilde.robin@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Maxime", lastName: "Henry", emails: [{ value: "maxime.henry@gmail.com", label: "personal" }], phones: [{ value: "+33 6 90 12 34 56", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Mehdi", lastName: "Bouaziz", emails: [{ value: "mehdi.bouaziz@outlook.com", label: "personal" }], phones: [{ value: "+216 55 123 456", label: "mobile" }], company: "Tunisian Tech Co" }),
|
||||||
|
c({ firstName: "Mélanie", lastName: "Gauthier", emails: [{ value: "melanie.gauthier@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Mohamed", lastName: "Saïdi", emails: [{ value: "mohamed.saidi@gmail.com", label: "personal" }], phones: [{ value: "+213 5 55 12 34 56", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Moussa", lastName: "Keita", emails: [{ value: "moussa.keita@yahoo.fr", label: "personal" }], phones: [], company: "Bamako Digital", jobTitle: "Fullstack Dev" }),
|
||||||
|
c({ firstName: "Nadia", lastName: "Haddad", emails: [{ value: "nadia.haddad@gmail.com", label: "personal" }], phones: [{ value: "+33 6 43 21 09 87", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Nathan", lastName: "Picard", emails: [{ value: "nathan.picard@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Nicolas", lastName: "Faure", emails: [{ value: "nicolas.faure@gmail.com", label: "personal" }], phones: [{ value: "+33 6 54 32 10 98", label: "mobile" }], birthday: { day: 12, month: 4, year: 1987 } }),
|
||||||
|
c({ firstName: "Noémie", lastName: "Gérard", emails: [{ value: "noemie.gerard@outlook.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Olivier", lastName: "Bertrand", emails: [{ value: "olivier.bertrand@gmail.com", label: "personal" }], phones: [{ value: "+33 6 65 43 21 09", label: "mobile" }], company: "Société Générale", jobTitle: "Risk Analyst" }),
|
||||||
|
c({ firstName: "Omar", lastName: "Fathi", emails: [{ value: "omar.fathi@outlook.com", label: "personal" }], phones: [{ value: "+20 100 123 4567", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Pascal", lastName: "Roger", emails: [{ value: "pascal.roger@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Patrick", lastName: "Müller", emails: [{ value: "patrick.muller@gmx.de", label: "personal" }], phones: [{ value: "+49 171 9876543", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Paul", lastName: "Lemoine", emails: [{ value: "paul.lemoine@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Pauline", lastName: "Dufour", emails: [{ value: "pauline.dufour@proton.me", label: "personal" }], phones: [{ value: "+33 6 76 54 32 10", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Pierre", lastName: "Arnaud", emails: [{ value: "pierre.arnaud@gmail.com", label: "personal" }], phones: [], company: "CNRS", jobTitle: "Research Scientist" }),
|
||||||
|
c({ firstName: "Priya", lastName: "Sharma", emails: [{ value: "priya.sharma@outlook.com", label: "personal" }], phones: [{ value: "+91 98765 43210", label: "mobile" }], company: "Infosys", jobTitle: "Team Lead" }),
|
||||||
|
c({ firstName: "Quentin", lastName: "Masson", emails: [{ value: "quentin.masson@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Rachid", lastName: "Ouali", emails: [{ value: "rachid.ouali@yahoo.fr", label: "personal" }], phones: [{ value: "+33 6 87 65 43 21", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Raphaël", lastName: "Collet", emails: [{ value: "raphael.collet@gmail.com", label: "personal" }], phones: [], nicknames: ["Raph"] }),
|
||||||
|
c({ firstName: "Rémi", lastName: "Brunet", emails: [{ value: "remi.brunet@proton.me", label: "personal" }], phones: [{ value: "+33 6 98 76 54 32", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Romain", lastName: "Guérin", emails: [{ value: "romain.guerin@gmail.com", label: "personal" }], phones: [], company: "Doctolib", jobTitle: "iOS Developer" }),
|
||||||
|
c({ firstName: "Saïd", lastName: "Mansouri", emails: [{ value: "said.mansouri@outlook.com", label: "personal" }], phones: [{ value: "+212 6 12 34 56 78", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Salima", lastName: "Benali", emails: [{ value: "salima.benali@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Samuel", lastName: "Torres", emails: [{ value: "samuel.torres@gmail.com", label: "personal" }], phones: [{ value: "+33 6 09 87 65 43", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Sandra", lastName: "Oliveira", emails: [{ value: "sandra.oliveira@outlook.com", label: "personal" }], phones: [{ value: "+351 912 345 678", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Sarah", lastName: "Cohen", emails: [{ value: "sarah.cohen@gmail.com", label: "personal" }], phones: [], birthday: { day: 8, month: 12, year: 1991 } }),
|
||||||
|
c({ firstName: "Sébastien", lastName: "André", emails: [{ value: "sebastien.andre@yahoo.fr", label: "personal" }], phones: [{ value: "+33 6 21 43 65 87", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Slim", lastName: "Gharbi", emails: [{ value: "slim.gharbi@gmail.com", label: "personal" }], phones: [{ value: "+216 98 765 432", label: "mobile" }], company: "Sofrecom" }),
|
||||||
|
c({ firstName: "Sofia", lastName: "Andersson", emails: [{ value: "sofia.andersson@outlook.se", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Sophie", lastName: "Legrand", emails: [{ value: "sophie.legrand@gmail.com", label: "personal" }], phones: [{ value: "+33 6 32 10 98 76", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Stéphane", lastName: "Michel", emails: [{ value: "stephane.michel@proton.me", label: "personal" }], phones: [], company: "Thales", jobTitle: "Systems Engineer" }),
|
||||||
|
c({ firstName: "Suki", lastName: "Tanaka", emails: [{ value: "suki.tanaka@gmail.com", label: "personal" }], phones: [{ value: "+81 80 9876 5432", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Sylvain", lastName: "Roche", emails: [{ value: "sylvain.roche@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Tao", lastName: "Li", emails: [{ value: "tao.li@qq.com", label: "personal" }], phones: [{ value: "+86 186 1234 5678", label: "mobile" }], company: "ByteDance" }),
|
||||||
|
c({ firstName: "Théo", lastName: "Roussel", emails: [{ value: "theo.roussel@yahoo.fr", label: "personal" }], phones: [{ value: "+33 6 43 56 78 90", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Thibault", lastName: "Caron", emails: [{ value: "thibault.caron@gmail.com", label: "personal" }], phones: [], birthday: { day: 19, month: 7, year: 1994 } }),
|
||||||
|
c({ firstName: "Thomas", lastName: "Giraud", emails: [{ value: "thomas.giraud@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Tristan", lastName: "Philippe", emails: [{ value: "tristan.philippe@gmail.com", label: "personal" }], phones: [{ value: "+33 6 54 67 89 01", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Ugo", lastName: "Ferreira", emails: [{ value: "ugo.ferreira@outlook.com", label: "personal" }], phones: [{ value: "+351 934 567 890", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Valentin", lastName: "Perrin", emails: [{ value: "valentin.perrin@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Vanessa", lastName: "Cruz", emails: [{ value: "vanessa.cruz@gmail.com", label: "personal" }], phones: [{ value: "+55 21 99876 5432", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Victor", lastName: "Dupuis", emails: [{ value: "victor.dupuis@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Vincent", lastName: "Morel", emails: [{ value: "vincent.morel@gmail.com", label: "personal" }], phones: [{ value: "+33 6 65 78 90 12", label: "mobile" }], company: "BlaBlaCar", jobTitle: "VP Engineering" }),
|
||||||
|
c({ firstName: "Wael", lastName: "Jbara", emails: [{ value: "wael.jbara@outlook.com", label: "personal" }], phones: [{ value: "+961 3 123 456", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Xavier", lastName: "Pons", emails: [{ value: "xavier.pons@gmail.com", label: "personal" }], phones: [], company: "Airbus", jobTitle: "Avionics Engineer" }),
|
||||||
|
c({ firstName: "Yanis", lastName: "Bouzid", emails: [{ value: "yanis.bouzid@proton.me", label: "personal" }], phones: [{ value: "+33 6 76 89 01 23", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Yasmine", lastName: "Chaoui", emails: [{ value: "yasmine.chaoui@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Youssef", lastName: "Alaoui", emails: [{ value: "youssef.alaoui@outlook.com", label: "personal" }], phones: [{ value: "+212 6 45 67 89 01", label: "mobile" }], company: "Royal Air Maroc", jobTitle: "IT Manager" }),
|
||||||
|
c({ firstName: "Zakaria", lastName: "Hamdi", emails: [{ value: "zakaria.hamdi@gmail.com", label: "personal" }], phones: [{ value: "+216 50 987 654", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Zoé", lastName: "Lefebvre", emails: [{ value: "zoe.lefebvre@yahoo.fr", label: "personal" }], phones: [], birthday: { day: 3, month: 5, year: 1996 } }),
|
||||||
|
c({ firstName: "Abdel", lastName: "Rahman", emails: [{ value: "abdel.rahman@gmail.com", label: "personal" }], phones: [{ value: "+20 122 345 6789", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Agathe", lastName: "Maillard", emails: [{ value: "agathe.maillard@outlook.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Albert", lastName: "Schmitt", emails: [{ value: "albert.schmitt@gmx.de", label: "personal" }], phones: [{ value: "+49 172 5551234", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Alice", lastName: "Delattre", emails: [{ value: "alice.delattre@gmail.com", label: "personal" }], phones: [{ value: "+33 6 11 33 55 77", label: "mobile" }], company: "Alan", jobTitle: "Product Designer" }),
|
||||||
|
c({ firstName: "Amine", lastName: "Louafi", emails: [{ value: "amine.louafi@yahoo.fr", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Ana", lastName: "García", emails: [{ value: "ana.garcia@gmail.com", label: "personal" }], phones: [{ value: "+34 678 901 234", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Andrei", lastName: "Volkov", emails: [{ value: "andrei.volkov@mail.ru", label: "personal" }], phones: [{ value: "+7 926 987 65 43", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Arnaud", lastName: "Charrier", emails: [{ value: "arnaud.charrier@proton.me", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Axel", lastName: "Jourdain", emails: [{ value: "axel.jourdain@gmail.com", label: "personal" }], phones: [{ value: "+33 6 22 44 66 88", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Basile", lastName: "Noël", emails: [{ value: "basile.noel@outlook.fr", label: "personal" }], phones: [], company: "Ubisoft Montpellier", jobTitle: "Level Designer" }),
|
||||||
|
c({ firstName: "Bilal", lastName: "Messaoudi", emails: [{ value: "bilal.messaoudi@gmail.com", label: "personal" }], phones: [{ value: "+213 6 61 23 45 67", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Paulina", lastName: "Wiśniewska", emails: [{ value: "paulina.w@outlook.com", label: "personal" }], phones: [{ value: "+48 502 345 678", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Robin", lastName: "Clément", emails: [{ value: "robin.clement@gmail.com", label: "personal" }], phones: [], nicknames: ["Rob"] }),
|
||||||
|
c({ firstName: "Sami", lastName: "Ferchichi", emails: [{ value: "sami.ferchichi@yahoo.fr", label: "personal" }], phones: [{ value: "+216 23 456 789", label: "mobile" }] }),
|
||||||
|
c({ firstName: "Thibaud", lastName: "Moulin", emails: [{ value: "thibaud.moulin@gmail.com", label: "personal" }], phones: [] }),
|
||||||
|
c({ firstName: "Victoire", lastName: "Leclère", emails: [{ value: "victoire.leclere@proton.me", label: "personal" }], phones: [{ value: "+33 6 87 09 12 34", label: "mobile" }] }),
|
||||||
|
c({ firstName: "William", lastName: "Hartmann", emails: [{ value: "william.hartmann@gmx.de", label: "personal" }], phones: [{ value: "+49 160 9876543", label: "mobile" }], company: "Bosch" }),
|
||||||
|
c({ firstName: "Yann", lastName: "Lecomte", emails: [{ value: "yann.lecomte@gmail.com", label: "personal" }], phones: [], company: "MistralAI", jobTitle: "ML Engineer" }),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase()
|
||||||
|
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase()
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
44
lib/contacts/types.ts
Normal file
44
lib/contacts/types.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { Contact } from "@/lib/compose-context"
|
||||||
|
|
||||||
|
export interface ContactAddress {
|
||||||
|
street?: string
|
||||||
|
city?: string
|
||||||
|
region?: string
|
||||||
|
postalCode?: string
|
||||||
|
country?: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullContact {
|
||||||
|
id: string
|
||||||
|
namePrefix?: string
|
||||||
|
firstName: string
|
||||||
|
middleName?: string
|
||||||
|
lastName: string
|
||||||
|
nameSuffix?: string
|
||||||
|
phoneticFirstName?: string
|
||||||
|
phoneticLastName?: string
|
||||||
|
nicknames?: string[]
|
||||||
|
company?: string
|
||||||
|
department?: string
|
||||||
|
jobTitle?: string
|
||||||
|
emails: { value: string; label: string }[]
|
||||||
|
phones: { value: string; label: string }[]
|
||||||
|
addresses?: ContactAddress[]
|
||||||
|
birthday?: { day?: number; month?: number; year?: number }
|
||||||
|
notes?: string
|
||||||
|
labels?: string[]
|
||||||
|
avatarUrl?: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullContactDisplayName(c: FullContact): string {
|
||||||
|
return `${c.firstName} ${c.lastName}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toComposeContact(c: FullContact): Contact {
|
||||||
|
const name = fullContactDisplayName(c)
|
||||||
|
const email = c.emails[0]?.value ?? ""
|
||||||
|
return { name, email }
|
||||||
|
}
|
||||||
@ -68,6 +68,7 @@
|
|||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"fuse.js": "^7.3.0",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
|||||||
@ -173,6 +173,9 @@ importers:
|
|||||||
emoji-mart:
|
emoji-mart:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
|
fuse.js:
|
||||||
|
specifier: ^7.3.0
|
||||||
|
version: 7.3.0
|
||||||
input-otp:
|
input-otp:
|
||||||
specifier: 1.4.2
|
specifier: 1.4.2
|
||||||
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@ -1652,6 +1655,10 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
fuse.js@7.3.0:
|
||||||
|
resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
get-nonce@1.0.1:
|
get-nonce@1.0.1:
|
||||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -3425,6 +3432,8 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
fuse.js@7.3.0: {}
|
||||||
|
|
||||||
get-nonce@1.0.1: {}
|
get-nonce@1.0.1: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user