Le studio d'e-mails et les invitations partent vraiment

Les deux écrans de composition gagnent le choix des destinataires — tous
les adhérents actifs, une catégorie de métier, ou les inscrits à la
rencontre — et un bouton qui envoie pour de bon. « M'envoyer un test »
part en direct, pour un verdict immédiat ; une diffusion passe par la
file, une ligne par destinataire.

Copier, brouillon Outlook et export HTML restent en place : ils servent
encore aux cas particuliers, et au cas où aucune boîte n'est configurée.

L'aperçu comme l'envoi s'appuient désormais sur l'adresse publique
enregistrée plutôt que sur `window.location.origin` : c'est celle que
verront les destinataires, pas celle par laquelle l'administrateur est
arrivé.

Un modérateur gère les rencontres mais n'écrit pas à tous les adhérents :
le bouton d'envoi du composeur reste conditionné à `manageEmails`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-14 16:19:28 +00:00
1 parent 877b0ece78
commit 158958a8f4
6 files changed
+440 -25

No files matched your search

+80 -7
View File
@@ -3,9 +3,32 @@
import { useEffect, useMemo, useState } from "react";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendSelfTest, sendStudioEmail } from "@/app/backend/actions";
export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [baseUrl, setBaseUrl] = useState("");
export function EmailCreator({
brand,
choices,
mailReady,
siteUrl,
}: {
brand: EmailBrand;
choices: RecipientChoices;
/** Faux si aucune boîte n'expédie : l'envoi réel est alors désactivé. */
mailReady: boolean;
/** Adresse publique enregistrée, utilisée par l'aperçu comme par l'envoi. */
siteUrl: string;
}) {
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const [form, setForm] = useState<GeneralEmailContent>({
subject: `Actualités de ${brand.associationName}`,
kicker: brand.associationName,
@@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [error, setError] = useState("");
useEffect(() => {
setBaseUrl(window.location.origin);
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
}, []);
// L'adresse enregistrée prime : c'est elle que verront les destinataires,
// et non celle par laquelle l'administrateur est arrivé.
const origin = siteUrl || window.location.origin;
setBaseUrl(origin);
setForm((current) => ({ ...current, buttonUrl: `${origin}/association` }));
}, [siteUrl]);
const email = useMemo(
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
[baseUrl, brand, form],
);
const recipientCount = audienceCount(audience, choices);
async function deliver(task: () => Promise<{ queued: number; audience: string } | { error: string }>) {
setSending(true);
setError("");
setSent(null);
try {
const result = await task();
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} message(s) mis en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof GeneralEmailContent, value: string) {
setForm((current) => ({ ...current, [name]: value }));
}
@@ -69,8 +112,37 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
</div>
<div style={{ marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
<div style={{ display: "grid", gap: 9, marginTop: 14 }}>
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={() => email && deliver(() => sendStudioEmail({ content: form, audience: audienceFrom(audience) }))}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} adhérent${recipientCount > 1 ? "s" : ""}`}
</button>
<button
type="button"
className="email-secondary-button"
disabled={!email || sending || !mailReady}
onClick={() => email && deliver(() => sendSelfTest({ subject: email.subject, html: email.html, text: form.body }))}
>
M&apos;envoyer un test
</button>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail n&apos;expédie pour l&apos;instant : branchez-la dans Configuration › Boîte mail.
Les boutons ci-dessous restent utilisables.
</p>
)}
</div>
</div>
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
<div style={{ display: "grid", gap: 9, marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
</button>
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
</div>
</div>
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires.
Ces trois boutons restent là pour les cas particuliers : le .eml donne un brouillon Outlook
à compléter à la main.
</p>
</section>
+104
View File
@@ -0,0 +1,104 @@
"use client";
import type { Audience } from "@/lib/mail-recipients";
/**
* Choix des destinataires, partagé par le studio de composition et le
* composeur d'invitations.
*
* Les effectifs sont calculés côté serveur et passés en props : le navigateur
* n'a jamais besoin de la liste des adresses pour afficher un compteur.
*/
export type RecipientChoices = {
all: number;
categories: { value: string; label: string; count: number }[];
meeting?: { id: number; title: string; count: number } | null;
};
export type AudienceState = { kind: Audience["kind"]; categoryId: string };
export function audienceFrom(state: AudienceState, meetingId?: number | null): Audience {
if (state.kind === "category" && state.categoryId) {
return { kind: "category", categoryId: Number(state.categoryId) };
}
if (state.kind === "meeting" && meetingId) return { kind: "meeting", meetingId };
return { kind: "all" };
}
export function audienceCount(state: AudienceState, choices: RecipientChoices): number {
if (state.kind === "category") {
return choices.categories.find((option) => option.value === state.categoryId)?.count ?? 0;
}
if (state.kind === "meeting") return choices.meeting?.count ?? 0;
return choices.all;
}
export function MailRecipientPicker({
choices,
value,
onChange,
}: {
choices: RecipientChoices;
value: AudienceState;
onChange: (next: AudienceState) => void;
}) {
const options: { kind: Audience["kind"]; label: string; hint: string; count: number }[] = [
{ kind: "all", label: "Tous les adhérents actifs", hint: "", count: choices.all },
];
if (choices.categories.length > 0) {
options.push({ kind: "category", label: "Une catégorie de métier", hint: "", count: 0 });
}
if (choices.meeting) {
options.push({
kind: "meeting",
label: "Les inscrits à cette rencontre",
hint: choices.meeting.title,
count: choices.meeting.count,
});
}
return (
<fieldset style={{ border: "none", margin: 0, padding: 0 }}>
<legend className="field-label" style={{ marginBottom: 8 }}>Destinataires</legend>
<div style={{ display: "grid", gap: 9 }}>
{options.map((option) => (
<div key={option.kind}>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", cursor: "pointer", fontSize: 13.5, color: "#3c3322" }}>
<input
type="radio"
name="audience"
checked={value.kind === option.kind}
onChange={() => onChange({ ...value, kind: option.kind })}
style={{ marginTop: 3 }}
/>
<span>
<strong style={{ color: "#26201a" }}>{option.label}</strong>
{option.kind !== "category" && (
<span style={{ color: "#9a8d72" }}> · {option.count} adresse{option.count > 1 ? "s" : ""}</span>
)}
{option.hint && <span style={{ display: "block", color: "#9a8d72", fontSize: 12.5 }}>{option.hint}</span>}
</span>
</label>
{option.kind === "category" && value.kind === "category" && (
<select
className="field"
value={value.categoryId}
onChange={(event) => onChange({ ...value, categoryId: event.target.value })}
style={{ marginTop: 8, marginLeft: 27, width: "calc(100% - 27px)" }}
>
<option value="">Choisir un métier…</option>
{choices.categories.map((category) => (
<option key={category.value} value={category.value}>
{category.label} ({category.count})
</option>
))}
</select>
)}
</div>
))}
</div>
</fieldset>
);
}
+66 -4
View File
@@ -4,20 +4,64 @@ import { useMemo, useState } from "react";
import { Modal } from "@/components/Modal";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendMeetingInvitations } from "@/app/backend/actions";
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
export function MeetingEmailComposer({
meeting,
brand,
choices,
mailReady,
siteUrl,
}: {
meeting: MeetingEmailData;
brand: EmailBrand;
choices: RecipientChoices;
mailReady: boolean;
siteUrl: string;
}) {
const [open, setOpen] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
const [flash, setFlash] = useState("");
const [error, setError] = useState("");
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
const recipientCount = audienceCount(audience, choices);
function show() {
setBaseUrl(window.location.origin);
// L'adresse enregistrée prime : c'est celle que verront les destinataires.
setBaseUrl(siteUrl || window.location.origin);
setOpen(true);
}
async function deliver() {
setSending(true);
setError("");
setSent(null);
try {
const result = await sendMeetingInvitations({
meetingId: meeting.id,
texts,
audience: audienceFrom(audience, meeting.id),
});
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} invitation(s) mises en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof MeetingEmailTexts, value: string) {
setTexts((current) => ({ ...current, [name]: value }));
}
@@ -46,8 +90,26 @@ export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmail
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
Date, lieu, description, places et lien d'inscription repris automatiquement.
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
</div>
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={deliver}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} destinataire${recipientCount > 1 ? "s" : ""}`}
</button>
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail ne répond : branchez-la dans Configuration › Boîte mail. Les boutons
ci-dessous restent utilisables.
</p>
)}
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
</button>
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>