SPB Git

Repository Public

MailClient.tsx 15.2 KB 403 lines tsx

1"use client";2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// /admin/courriel — composants interactifs. Composeur v3 : gabarits Groupe KA4// (sélecteur visuel avec mini-aperçus, aperçu fidèle en iframe sandbox rendu5// par le serveur), pièces jointes (3 × 8 Mo), envoi multipart. L'expéditeur6// et l'HTML final sont TOUJOURS décidés serveur — le client n'envoie que du7// texte et l'identifiant du gabarit.8import { useRef, useState } from "react";9import { useRouter } from "next/navigation";10import { MAIL_TEMPLATES } from "@/lib/comms/mail-templates";1112function fmtBytes(n: number): string {13  if (n < 1024) return `${n} o`;14  if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;15  return `${(n / 1024 / 1024).toFixed(1)} Mo`;16}1718/** Mini-aperçu stylisé d'un gabarit (pur CSS — l'aperçu fidèle est l'iframe). */19function TemplateThumb({ id, accent }: { id: string; accent: string }) {20  return (21    <span className="flex h-[52px] w-full flex-col justify-between rounded-md border border-[rgba(20,24,20,0.35)] bg-[#f5f3ee] p-[6px]" aria-hidden="true">22      <span className="flex items-center gap-1">23        <span className="gk-display text-[7px] leading-none font-bold">24          G<span className="rounded-[2px] bg-lime px-[2px]">KA</span>25        </span>26        {id === "annonce" ? (27          <span className="ml-auto h-[10px] w-[52%] rounded-[2px] bg-ink" />28        ) : null}29      </span>30      <span31        className={`flex flex-col gap-[3px] rounded-[3px] p-[4px] ${id === "simple" ? "" : "border border-ink bg-white shadow-[2px_2px_0_rgba(20,24,20,0.55)]"}`}32      >33        <span className="h-[3px] w-[38%] rounded-full" style={{ background: accent }} />34        <span className="h-[2.5px] w-[92%] rounded-full bg-[rgba(20,24,20,0.28)]" />35        <span className="h-[2.5px] w-[74%] rounded-full bg-[rgba(20,24,20,0.28)]" />36        <span className="mt-[2px] h-[3px] w-[30%] rounded-full bg-[rgba(28,92,65,0.55)]" />37      </span>38    </span>39  );40}4142export function MailComposer({43  fromEmail,44  replyTo,45  startOpen = false,46}: {47  fromEmail: string;48  /** préremplissage en mode réponse */49  replyTo?: { id: number; to: string; subject: string } | null;50  startOpen?: boolean;51}) {52  const router = useRouter();53  const [open, setOpen] = useState(startOpen);54  const [to, setTo] = useState(replyTo?.to ?? "");55  const [cc, setCc] = useState("");56  const [subject, setSubject] = useState(57    replyTo ? (replyTo.subject.startsWith("Re:") ? replyTo.subject : `Re: ${replyTo.subject}`) : "",58  );59  const [body, setBody] = useState("");60  const [template, setTemplate] = useState("simple");61  const [files, setFiles] = useState<File[]>([]);62  const [busy, setBusy] = useState(false);63  const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);64  const [preview, setPreview] = useState<string | null>(null);65  const [previewBusy, setPreviewBusy] = useState(false);66  const fileRef = useRef<HTMLInputElement>(null);6768  function addFiles(list: FileList | null) {69    if (!list) return;70    const next = [...files];71    for (const f of Array.from(list)) {72      if (next.length >= 3) break;73      if (f.size > 8 * 1024 * 1024) {74        setMsg({ ok: false, text: `« ${f.name} » dépasse 8 Mo.` });75        continue;76      }77      if (!next.some((x) => x.name === f.name && x.size === f.size)) next.push(f);78    }79    setFiles(next);80    if (fileRef.current) fileRef.current.value = "";81  }8283  async function showPreview() {84    setPreviewBusy(true);85    try {86      const res = await fetch("/api/admin/mail/preview", {87        method: "POST",88        headers: { "Content-Type": "application/json" },89        body: JSON.stringify({ template, subject, body }),90      });91      const data = (await res.json().catch(() => ({}))) as { html?: string };92      if (res.ok && data.html) setPreview(data.html);93    } catch {94      setMsg({ ok: false, text: "Aperçu indisponible — réseau ?" });95    }96    setPreviewBusy(false);97  }9899  async function send(e: React.FormEvent) {100    e.preventDefault();101    if (busy) return;102    setBusy(true);103    setMsg(null);104    try {105      const fd = new FormData();106      fd.set("to", to);107      fd.set("cc", cc);108      fd.set("subject", subject);109      fd.set("body", body);110      fd.set("template", template);111      if (replyTo) fd.set("reply_to_id", String(replyTo.id));112      for (const f of files) fd.append("files", f);113      const res = await fetch("/api/admin/mail/send", { method: "POST", body: fd });114      const data = (await res.json().catch(() => ({}))) as { error?: string };115      if (res.ok) {116        setMsg({ ok: true, text: "Courriel envoyé — il est dans vos Envoyés." });117        setBody("");118        setFiles([]);119        if (!replyTo) {120          setTo("");121          setCc("");122          setSubject("");123        }124        router.refresh();125      } else setMsg({ ok: false, text: data.error ?? "Envoi refusé." });126    } catch {127      setMsg({ ok: false, text: "Réseau indisponible." });128    }129    setBusy(false);130  }131132  if (!open)133    return (134      <button className="btn btn-primary" onClick={() => setOpen(true)}>135        {replyTo ? "Répondre" : "Nouveau courriel"}136      </button>137    );138139  return (140    <>141      <form onSubmit={send} className="gk-card mt-2 w-full p-5 !shadow-none sm:p-6">142        <div className="flex items-center justify-between gap-3">143          <p className="kicker">{replyTo ? "Répondre" : "Nouveau courriel"}</p>144          <button145            type="button"146            className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.1em] text-ink-3 uppercase hover:text-ink"147            onClick={() => setOpen(false)}148          >149            Fermer ✕150          </button>151        </div>152        <p className="gk-mono mt-3 text-[11px] text-ink-3">153          De : <strong className="text-ink">{fromEmail}</strong> · signé automatiquement154        </p>155156        <div className="mt-4 grid gap-3 sm:grid-cols-2">157          <div className={replyTo ? "sm:col-span-2" : ""}>158            <label className="klabel mb-[4px] block" htmlFor="mc-to">À (virgules pour plusieurs)</label>159            <input id="mc-to" className="field !py-[9px]" value={to} onChange={(e) => setTo(e.target.value)} required placeholder="personne@exemple.com" />160          </div>161          {!replyTo ? (162            <div>163              <label className="klabel mb-[4px] block" htmlFor="mc-cc">Cc (facultatif)</label>164              <input id="mc-cc" className="field !py-[9px]" value={cc} onChange={(e) => setCc(e.target.value)} />165            </div>166          ) : null}167        </div>168        <label className="klabel mt-3 mb-[4px] block" htmlFor="mc-subject">Sujet</label>169        <input id="mc-subject" className="field !py-[9px]" value={subject} onChange={(e) => setSubject(e.target.value)} required />170171        {/* ---------- gabarit ---------- */}172        <p className="klabel mt-5 mb-[6px]">Gabarit Groupe KA</p>173        <div className="grid grid-cols-3 gap-2 sm:grid-cols-6">174          {MAIL_TEMPLATES.map((t) => (175            <button176              key={t.id}177              type="button"178              onClick={() => setTemplate(t.id)}179              title={t.desc}180              aria-pressed={template === t.id}181              className={`flex cursor-pointer flex-col gap-[6px] rounded-lg border-[1.5px] p-2 text-left transition-transform ${182                template === t.id183                  ? "border-ink bg-lime-soft shadow-[3px_3px_0_rgba(20,24,20,0.85)]"184                  : "border-[rgba(20,24,20,0.25)] bg-surface hover:-translate-y-[1px] hover:border-ink"185              }`}186            >187              <TemplateThumb id={t.id} accent={t.accent} />188              <span className="gk-display text-[11px] leading-tight font-bold">{t.label}</span>189            </button>190          ))}191        </div>192        <p className="gk-mono mt-[6px] text-[10px] leading-relaxed text-ink-3">193          {MAIL_TEMPLATES.find((t) => t.id === template)?.desc} Votre message est194          habillé aux couleurs Groupe KA et signé « {fromEmail} ».195        </p>196197        <label className="klabel mt-4 mb-[4px] block" htmlFor="mc-body">Message</label>198        <textarea199          id="mc-body"200          rows={9}201          className="field resize-y"202          value={body}203          onChange={(e) => setBody(e.target.value)}204          required205          placeholder={"Bonjour,\n\nVotre texte — les doubles sauts de ligne deviennent des paragraphes.\n\nLa signature est ajoutée automatiquement."}206        />207208        {/* ---------- pièces jointes ---------- */}209        <p className="klabel mt-4 mb-[6px]">Pièces jointes (3 max · 8 Mo chacune)</p>210        <div className="flex flex-wrap items-center gap-2">211          {files.map((f, i) => (212            <span key={`${f.name}-${i}`} className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-[7px] text-[11px] font-bold">213              📎 {f.name}214              <span className="font-normal text-ink-3">{fmtBytes(f.size)}</span>215              <button216                type="button"217                aria-label={`Retirer ${f.name}`}218                className="cursor-pointer text-danger hover:scale-110"219                onClick={() => setFiles(files.filter((_, j) => j !== i))}220              >221                ✕222              </button>223            </span>224          ))}225          {files.length < 3 ? (226            <label className="gk-mono inline-flex cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-dashed border-[rgba(20,24,20,0.45)] px-3 py-[7px] text-[11px] font-bold text-ink-2 hover:border-ink hover:bg-lime-soft">227              + Ajouter un fichier228              <input229                ref={fileRef}230                type="file"231                multiple232                className="sr-only"233                accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.png,.jpg,.jpeg,.webp,.zip"234                onChange={(e) => addFiles(e.target.files)}235              />236            </label>237          ) : null}238        </div>239240        <div className="mt-5 flex flex-wrap items-center gap-3">241          <button type="submit" className="btn btn-primary !min-h-[42px] !px-6" disabled={busy}>242            {busy ? "Envoi…" : `Envoyer${files.length ? ` (${files.length} 📎)` : ""}`}243          </button>244          <button245            type="button"246            className="btn btn-ghost !min-h-[42px] !px-5"247            onClick={showPreview}248            disabled={previewBusy}249          >250            {previewBusy ? "Rendu…" : "Aperçu du courriel"}251          </button>252          <p className="gk-mono max-w-[260px] text-[9.5px] leading-relaxed text-ink-3">253            Envoyé par Resend depuis votre adresse — copie et pièces jointes254            conservées dans la base.255          </p>256        </div>257        {msg ? (258          <p className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${msg.ok ? "border-green bg-lime-soft text-green" : "border-danger bg-[var(--danger-soft)] text-danger"}`}>259            {msg.text}260          </p>261        ) : null}262      </form>263264      {/* ---------- aperçu fidèle (rendu serveur, iframe sandbox) ---------- */}265      {preview !== null ? (266        <div267          className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-[rgba(20,24,20,0.55)] p-3 sm:p-8"268          role="dialog"269          aria-label="Aperçu du courriel"270          onClick={() => setPreview(null)}271        >272          <div273            className="flex h-full max-h-[860px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border-2 border-ink bg-paper shadow-[10px_10px_0_rgba(20,24,20,0.5)]"274            onClick={(e) => e.stopPropagation()}275          >276            <div className="flex items-center justify-between border-b-[1.5px] border-ink bg-ink px-4 py-3">277              <p className="gk-mono text-[10px] font-bold tracking-[0.18em] text-lime uppercase">278                Aperçu — ce que recevra le destinataire279              </p>280              <button281                className="gk-mono cursor-pointer text-[11px] font-bold tracking-[0.1em] text-paper uppercase hover:text-lime"282                onClick={() => setPreview(null)}283              >284                Fermer ✕285              </button>286            </div>287            <iframe288              srcDoc={preview}289              sandbox=""290              title="Aperçu du courriel"291              className="h-full w-full flex-1 bg-white"292            />293          </div>294        </div>295      ) : null}296    </>297  );298}299300export function MailActions({301  id,302  folder,303  isRead,304  unrouted,305}: {306  id: number;307  folder: string;308  isRead: boolean;309  unrouted: boolean;310}) {311  const router = useRouter();312  const [busy, setBusy] = useState(false);313314  async function act(patch: Record<string, unknown>, thenList = false) {315    setBusy(true);316    await fetch(`/api/admin/mail/${id}`, {317      method: "PATCH",318      headers: { "Content-Type": "application/json" },319      body: JSON.stringify(patch),320    }).catch(() => {});321    setBusy(false);322    if (thenList) router.push("/admin/courriel");323    router.refresh();324  }325326  const b = "btn btn-ghost !min-h-[34px] !px-4 !text-[12px]";327  return (328    <div className="flex flex-wrap gap-2">329      {unrouted ? (330        <button className={`${b} !bg-lime`} disabled={busy} onClick={() => act({ claim: true })}>331          M&apos;attribuer ce message332        </button>333      ) : null}334      <button className={b} disabled={busy} onClick={() => act({ is_read: !isRead })}>335        {isRead ? "Marquer non lu" : "Marquer lu"}336      </button>337      {folder !== "archive" ? (338        <button className={b} disabled={busy} onClick={() => act({ folder: "archive" }, true)}>339          Archiver340        </button>341      ) : null}342      {folder !== "trash" ? (343        <button className={b} disabled={busy} onClick={() => act({ folder: "trash" }, true)}>344          Corbeille345        </button>346      ) : (347        <button className={b} disabled={busy} onClick={() => act({ folder: "inbox" })}>348          Restaurer349        </button>350      )}351    </div>352  );353}354355/** Actions rapides d'une rangée de la liste (desktop) : lu/non-lu, archiver. */356export function MailRowQuick({357  id,358  isRead,359  folder,360}: {361  id: number;362  isRead: boolean;363  folder: string;364}) {365  const router = useRouter();366  const [busy, setBusy] = useState(false);367368  async function act(patch: Record<string, unknown>) {369    setBusy(true);370    await fetch(`/api/admin/mail/${id}`, {371      method: "PATCH",372      headers: { "Content-Type": "application/json" },373      body: JSON.stringify(patch),374    }).catch(() => {});375    setBusy(false);376    router.refresh();377  }378379  const b =380    "gk-mono cursor-pointer rounded-md border border-[rgba(20,24,20,0.3)] bg-surface px-2 py-[5px] text-[9px] font-bold tracking-[0.06em] uppercase text-ink-2 hover:bg-lime-soft disabled:opacity-40";381  return (382    <span className="hidden flex-none items-center gap-1 sm:flex">383      <button384        className={b}385        disabled={busy}386        title={isRead ? "Marquer non lu" : "Marquer lu"}387        onClick={() => act({ is_read: !isRead })}388      >389        {isRead ? "Non lu" : "Lu"}390      </button>391      {folder !== "archive" ? (392        <button className={b} disabled={busy} title="Archiver" onClick={() => act({ folder: "archive" })}>393          Archiver394        </button>395      ) : (396        <button className={b} disabled={busy} title="Restaurer" onClick={() => act({ folder: "inbox" })}>397          Restaurer398        </button>399      )}400    </span>401  );402}403
Files main
Menu

Theme