mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 07:08:05 +02:00
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:
6 files changed
+440
-25
No files matched your search
@@ -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'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'expédie pour l'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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`))}>
|
||||
|
||||
Reference in new issue
Block a user