TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1"use client";2import { useEffect, useState } from "react";3import Link from "next/link";4import { Notice } from "./ui";5import { IconHeart, IconCompare, IconShare } from "./icons";6import { useFavorites, useCompare } from "@/lib/favorites";78function minSlot() { return new Date(Date.now() + 3600000).toISOString().slice(0, 16); }9function idemKey(listingId: string, body: string) { return `${listingId}-${body.length}-${Math.floor(Date.now() / 16384)}`; }10/** Panneau d'action d'une annonce directe Toveli : contacter, demander une visite, signaler. Connexion requise pour écrire (retour automatique sur la fiche). */11export function ContactPanel({ listingId, uid, publisherLabel, publisherName }: { listingId: string; uid: string; publisherLabel: string; publisherName: string | null }) {12 const [me, setMe] = useState<{ id: string } | null | undefined>(undefined);13 const [mode, setMode] = useState<"idle" | "contact" | "visit" | "report">("idle"); const [body, setBody] = useState(""); const [slots, setSlots] = useState<string[]>(["", "", ""]); const [note, setNote] = useState(""); const [reason, setReason] = useState("arnaque"); const [msg, setMsg] = useState<string | null>(null); const [busy, setBusy] = useState(false);14 const fav = useFavorites(); const cmp = useCompare();15 useEffect(() => { fetch("/api/me").then((r) => r.json()).then((d) => setMe(d.user ?? null)).catch(() => setMe(null)); }, []);16 const here = typeof window !== "undefined" ? window.location.pathname : `/annonce/${encodeURIComponent(uid)}`;17 const need = () => { if (me === null) { window.location.href = `/connexion?next=${encodeURIComponent(here)}`; return true; } return false; };18 const toISO = (v: string) => { const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };19 const send = async () => {20 setBusy(true); setMsg(null);21 try {22 if (mode === "contact") { const r = await fetch(`/api/listings/${listingId}/inquiries`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body, category: "INFO", idempotencyKey: idemKey(listingId, body) }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Message envoyé. Suivez la conversation dans Mes messages."); setBody(""); }23 if (mode === "visit") { const iso = slots.map(toISO).filter((x): x is string => !!x); if (!iso.length) throw new Error("Proposez au moins un créneau"); const r = await fetch(`/api/listings/${listingId}/viewings`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slots: iso, note: note || undefined }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Demande de visite envoyée. Vous serez avisé par courriel de la confirmation."); }24 if (mode === "report") { const r = await fetch("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetType: "LISTING", targetId: listingId, reason, details: note || undefined }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Signalement transmis à l'équipe Toveli. Merci."); }25 setMode("idle");26 } catch (e) { setMsg((e as Error).message); } finally { setBusy(false); }27 };28 const share = async () => { const u = window.location.href; if (navigator.share) { try { await navigator.share({ url: u, title: document.title }); } catch { /* */ } } else { await navigator.clipboard.writeText(u); setMsg("Lien copié"); } };29 return (30 <div className="tv-card p-4 space-y-3">31 <div><p className="tv-label">Publiée sur Toveli</p><p className="font-semibold">{publisherLabel}{publisherName ? ` · ${publisherName}` : ""}</p><p className="text-xs text-ink-3">Les échanges passent par la messagerie Toveli : aucune coordonnée n'est affichée publiquement.</p></div>32 <div className="grid grid-cols-2 gap-2">33 <button type="button" onClick={() => { if (!need()) setMode(mode === "contact" ? "idle" : "contact"); }} className="tv-btn tv-btn-primary">Contacter</button>34 <button type="button" onClick={() => { if (!need()) setMode(mode === "visit" ? "idle" : "visit"); }} className="tv-btn tv-btn-ghost">Demander une visite</button>35 </div>36 {me === null && <p className="text-xs text-ink-3">Connexion par code courriel requise pour écrire au propriétaire (30 secondes). <Link href={`/connexion?next=${encodeURIComponent(here)}`} className="underline">Se connecter</Link></p>}37 {mode === "contact" && <div className="space-y-2"><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} maxLength={3000} placeholder="Bonjour, je suis intéressé(e) par votre propriété. Est-elle toujours disponible ? …" className="w-full border border-line rounded-sm p-2 text-sm outline-none focus:border-brand" /><button type="button" disabled={busy || body.trim().length < 10} onClick={send} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Envoyer le message"}</button></div>}38 {mode === "visit" && <div className="space-y-2"><p className="text-xs text-ink-2">Proposez jusqu'à 3 créneaux (heure de l'Est) ; le propriétaire en confirme un.</p>{slots.map((s, i) => <input key={i} type="datetime-local" value={s} onChange={(e) => setSlots(slots.map((x, k) => k === i ? e.target.value : x))} min={minSlot()} className="tv-chip w-full !pl-3" />)}<input value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} placeholder="Note (facultatif)" className="tv-chip w-full !pl-3" /><button type="button" disabled={busy} onClick={send} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Envoyer la demande"}</button></div>}39 {mode === "report" && <div className="space-y-2"><select value={reason} onChange={(e) => setReason(e.target.value)} className="tv-chip w-full appearance-none"><option value="arnaque">Arnaque ou fraude présumée</option><option value="doublon">Doublon</option><option value="informations_fausses">Informations fausses</option><option value="deja_vendu_loue">Déjà vendu / loué</option><option value="droits_photos">Photos sans droits</option><option value="contenu_inapproprie">Contenu inapproprié</option><option value="autre">Autre</option></select><textarea value={note} onChange={(e) => setNote(e.target.value)} rows={3} maxLength={1000} placeholder="Précisions (facultatif)" className="w-full border border-line rounded-sm p-2 text-sm" /><button type="button" disabled={busy} onClick={send} className="tv-btn tv-btn-ghost w-full">Envoyer le signalement</button></div>}40 {msg && <Notice>{msg}</Notice>}41 <div className="flex flex-wrap gap-2 pt-1 border-t border-line">42 <button type="button" onClick={() => fav.toggle(uid)} className={`tv-chip ${fav.has(uid) ? "!text-brand-dark !border-brand" : ""}`}><IconHeart width={14} height={14} filled={fav.has(uid)} /> {fav.has(uid) ? "Favori" : "Ajouter aux favoris"}</button>43 <button type="button" onClick={() => cmp.toggle(uid)} className="tv-chip"><IconCompare width={14} height={14} /> Comparer</button>44 <button type="button" onClick={share} className="tv-chip"><IconShare width={14} height={14} /> Partager</button>45 <button type="button" onClick={() => setMode(mode === "report" ? "idle" : "report")} className="tv-chip !text-ink-3">Signaler</button>46 </div>47 </div>48 );49}50