- Created a .cursorignore file to manage local environment files. - Updated .env.example to reflect changes in the public app URL. - Modified the gmail workspace configuration to include the drive-suite path. - Enhanced email view components to support attachment handling and fallback for plain text bodies. - Improved user experience by updating attachment display logic and integrating inline attachment support.
933 lines
29 KiB
TypeScript
933 lines
29 KiB
TypeScript
"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 {
|
|
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 { useContactsList } from "@/lib/contacts/use-contacts-list"
|
|
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
|
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
|
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
import { useNavStore } from "@/lib/stores/nav-store"
|
|
import {
|
|
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
|
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
|
|
CONTACTS_PANEL_CARD_CLASS,
|
|
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
|
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
|
CONTACTS_PANEL_HEADER_CLASS,
|
|
CONTACTS_PANEL_ICON_BTN_CLASS,
|
|
CONTACTS_PANEL_LINK_TEXT_CLASS,
|
|
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
|
CONTACTS_PANEL_POPOVER_ITEM_CLASS,
|
|
CONTACTS_PANEL_SAVE_BTN_CLASS,
|
|
CONTACTS_PANEL_SELECT_TRIGGER_CLASS,
|
|
CONTACTS_PANEL_SHELL_CLASS,
|
|
CONTACTS_PANEL_TAG_CLASS,
|
|
CONTACTS_MUTED_TEXT,
|
|
} from "@/lib/contacts-chrome-classes"
|
|
import { cn } from "@/lib/utils"
|
|
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
|
|
|
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 {
|
|
setView,
|
|
showContactsList,
|
|
closePanel,
|
|
createDraft,
|
|
clearCreateDraft,
|
|
} = useContactsStore()
|
|
const { contacts, bookId } = useContactsList()
|
|
const createContactMutation = useCreateContact()
|
|
const updateContactMutation = useUpdateContact()
|
|
const labelRows = useNavStore((s) => s.labelRows)
|
|
const [starred, setStarred] = useState(false)
|
|
const [nameExpanded, setNameExpanded] = useState(false)
|
|
const [companyExpanded, setCompanyExpanded] = useState(false)
|
|
const hydratedEditIdRef = useRef<string | null>(null)
|
|
|
|
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: [],
|
|
})
|
|
clearCreateDraft()
|
|
}, [mode, createDraft, reset, clearCreateDraft])
|
|
|
|
useEffect(() => {
|
|
if (mode !== "edit" || !contactId) {
|
|
hydratedEditIdRef.current = null
|
|
return
|
|
}
|
|
if (hydratedEditIdRef.current === contactId) return
|
|
const contact = contacts.find((c) => c.id === contactId)
|
|
if (!contact) return
|
|
hydratedEditIdRef.current = contactId
|
|
|
|
const hasExtendedName = !!(
|
|
contact.namePrefix ||
|
|
contact.middleName ||
|
|
contact.nameSuffix ||
|
|
contact.phoneticFirstName ||
|
|
contact.phoneticLastName
|
|
)
|
|
if (hasExtendedName) setNameExpanded(true)
|
|
if (contact.department) setCompanyExpanded(true)
|
|
|
|
reset({
|
|
namePrefix: contact.namePrefix ?? "",
|
|
firstName: contact.firstName,
|
|
middleName: contact.middleName ?? "",
|
|
lastName: contact.lastName,
|
|
nameSuffix: contact.nameSuffix ?? "",
|
|
phoneticFirstName: contact.phoneticFirstName ?? "",
|
|
phoneticLastName: contact.phoneticLastName ?? "",
|
|
company: contact.company ?? "",
|
|
department: contact.department ?? "",
|
|
jobTitle: contact.jobTitle ?? "",
|
|
emails: contact.emails.length
|
|
? contact.emails
|
|
: [{ value: "", label: "Domicile" }],
|
|
phones: contact.phones.length
|
|
? contact.phones
|
|
: [{ value: "", label: "Mobile" }],
|
|
addresses: contact.addresses ?? [],
|
|
birthday: contact.birthday ?? {
|
|
day: undefined,
|
|
month: undefined,
|
|
year: undefined,
|
|
},
|
|
notes: contact.notes ?? "",
|
|
labels: contact.labels ?? [],
|
|
})
|
|
}, [mode, contactId, contacts, 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 tempId = crypto.randomUUID()
|
|
const fullContact: FullContact = {
|
|
id: tempId,
|
|
...payload,
|
|
firstName: payload.firstName ?? "",
|
|
lastName: payload.lastName ?? "",
|
|
emails: payload.emails ?? [],
|
|
phones: payload.phones ?? [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
createContactMutation.mutate(
|
|
{ bookId, contact: fullContactToApiContact(fullContact) },
|
|
{
|
|
onSuccess: (created) => {
|
|
const id = created?.uid ?? tempId
|
|
setView("view", id)
|
|
},
|
|
},
|
|
)
|
|
} else if (contactId) {
|
|
const fullContact: FullContact = {
|
|
id: contactId,
|
|
...payload,
|
|
firstName: payload.firstName ?? "",
|
|
lastName: payload.lastName ?? "",
|
|
emails: payload.emails ?? [],
|
|
phones: payload.phones ?? [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
updateContactMutation.mutate({
|
|
path: contactId,
|
|
contact: fullContactToApiContact(fullContact),
|
|
})
|
|
setView("view", contactId)
|
|
}
|
|
}
|
|
|
|
const availableLabels = labelRows.filter((r) => r.enabled !== false)
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
|
|
>
|
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
|
|
|
<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={cn("h-4 w-4", starred ? "fill-yellow-400 text-yellow-400" : CONTACTS_PANEL_MUTED_ICON_CLASS)}
|
|
/>
|
|
</Button>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={!canSave}
|
|
className={CONTACTS_PANEL_SAVE_BTN_CLASS}
|
|
>
|
|
Enregistrer
|
|
</button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
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={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
|
|
<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={CONTACTS_PANEL_TAG_CLASS}
|
|
>
|
|
{row && (
|
|
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
|
)}
|
|
{row?.label ?? labelId}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleLabel(labelId)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
)
|
|
})}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={CONTACTS_PANEL_ADD_TAG_BTN_CLASS}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Libellé
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-52 p-1" align="center">
|
|
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
|
|
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={CONTACTS_PANEL_POPOVER_ITEM_CLASS}
|
|
>
|
|
<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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{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={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
|
|
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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<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={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
|
|
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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{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={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
|
|
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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{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={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
|
|
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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
{addressFields.map((field, index) => (
|
|
<div key={field.id} className={CONTACTS_PANEL_CARD_CLASS}>
|
|
<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={cn("h-7 w-7 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
|
|
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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<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={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
|
|
<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={cn("flex items-center gap-2 py-1", CONTACTS_PANEL_LINK_TEXT_CLASS, "hover:text-primary/80")}
|
|
>
|
|
<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?.value) setFilled(true)
|
|
}, [defaultValue])
|
|
|
|
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={cn(CONTACTS_PANEL_FLOATING_INPUT_CLASS, 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={cn(
|
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
|
floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm",
|
|
focused ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{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?.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={cn(CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS, 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={cn(
|
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
|
floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm",
|
|
focused ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{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={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
|
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
}
|