This commit is contained in:
R3D347HR4Y 2026-05-18 17:47:32 +02:00
parent 18cb1257f4
commit ae54fa29e4
18 changed files with 2017 additions and 6 deletions

View File

@ -19,6 +19,7 @@ import { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header"
import { EmailList } from "@/components/gmail/email-list"
import { RightPanel } from "@/components/gmail/right-panel"
import { ContactsPanel } from "@/components/gmail/contacts/contacts-panel"
import { EmailDragProvider } from "@/lib/drag-context"
import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator"
import { ComposeProvider } from "@/lib/compose-context"
@ -179,6 +180,7 @@ function MailAppInner() {
>
<RightPanel />
</div>
<ContactsPanel />
</div>
{!splitView ? (
<MobileBottomBar

View File

@ -1,7 +1,7 @@
"use client"
import type { MouseEvent, ReactNode } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
HoverCard,
HoverCardContent,
@ -24,6 +24,11 @@ import {
Video,
} from "lucide-react"
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 { useCoarsePointer } from "@/hooks/use-touch-nav"
@ -50,6 +55,9 @@ export function ContactHoverCard({
side = "bottom",
}: ContactHoverCardProps) {
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 coarsePointer = useCoarsePointer()
const triggerRef = useRef<HTMLSpanElement>(null)
@ -60,6 +68,25 @@ export function ContactHoverCard({
const email = resolveSenderEmail(displayName, emailOverride)
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(() => {
allowHoverOpenRef.current = true
setOpen(true)
@ -164,7 +191,13 @@ export function ContactHoverCard({
variant="ghost"
size="icon"
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} />
</Button>
@ -213,9 +246,22 @@ export function ContactHoverCard({
<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]"
onClick={(e) => {
e.stopPropagation()
openContactsPanel()
}}
>
{matchedContact ? (
<>
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>
</div>
</HoverCardContent>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1 @@
export { ContactsPanel } from "./contacts-panel"

View File

@ -2,8 +2,12 @@
import { Calendar, Users, CheckSquare, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { cn } from "@/lib/utils"
export function RightPanel() {
const { panelOpen, togglePanel } = useContactsStore()
return (
<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">
@ -12,7 +16,16 @@ export function RightPanel() {
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
<CheckSquare className="h-4 w-4" />
</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" />
</Button>
<div className="flex-1" />

View 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 }),
}
)
)

View 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(),
}
}

View 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
View 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
View 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
View 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 }
}

View File

@ -68,6 +68,7 @@
"dayjs": "^1.11.20",
"embla-carousel-react": "8.6.0",
"emoji-mart": "^5.6.0",
"fuse.js": "^7.3.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"next": "16.2.6",

View File

@ -173,6 +173,9 @@ importers:
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
fuse.js:
specifier: ^7.3.0
version: 7.3.0
input-otp:
specifier: 1.4.2
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:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fuse.js@7.3.0:
resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==}
engines: {node: '>=10'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
@ -3425,6 +3432,8 @@ snapshots:
fraction.js@5.3.4: {}
fuse.js@7.3.0: {}
get-nonce@1.0.1: {}
graceful-fs@4.2.11: {}

File diff suppressed because one or more lines are too long