mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +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
+153
-2
@@ -51,12 +51,20 @@ import {
|
||||
saveOAuthApp,
|
||||
saveSmtpAccount,
|
||||
setActiveMailProvider,
|
||||
isMailConfigured,
|
||||
} from "@/lib/mail-accounts";
|
||||
import type { MailProvider } from "@/db/schema";
|
||||
import { sendNow } from "@/lib/mailer";
|
||||
import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox";
|
||||
import { activeMemberRecipients, selfRecipient } from "@/lib/mail-recipients";
|
||||
import { buildCredentialsEmail, buildGeneralEmail, buildInformationEmail } from "@/lib/email-templates";
|
||||
import { activeMemberRecipients, resolveAudience, selfRecipient, type Audience } from "@/lib/mail-recipients";
|
||||
import {
|
||||
buildCredentialsEmail,
|
||||
buildGeneralEmail,
|
||||
buildInformationEmail,
|
||||
buildMeetingEmail,
|
||||
type GeneralEmailContent,
|
||||
type MeetingEmailTexts,
|
||||
} from "@/lib/email-templates";
|
||||
import { richTextToPlain } from "@/lib/rich-text";
|
||||
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
@@ -1774,3 +1782,146 @@ export async function cancelMail(formData: FormData) {
|
||||
if (id) await cancelMailMessage(id);
|
||||
revalidatePath("/backend/boite-mail");
|
||||
}
|
||||
|
||||
// ---- Envois groupés ----
|
||||
|
||||
async function requireEmails() {
|
||||
const access = await requireRole();
|
||||
if (!can(access.role, "manageEmails")) throw new Error("Accès refusé");
|
||||
return access;
|
||||
}
|
||||
|
||||
export type QueuedBroadcast = { queued: number; audience: string };
|
||||
|
||||
/**
|
||||
* Prépare un envoi groupé.
|
||||
*
|
||||
* Le message est construit **une seule fois** puis recopié par destinataire :
|
||||
* une ligne chacun, jamais de copie partagée. Rien ne part dans la requête —
|
||||
* la file est vidée par la boucle de fond, sinon la page attendrait autant
|
||||
* d'allers-retours que d'adhérents.
|
||||
*/
|
||||
async function queueBroadcast(
|
||||
message: { subject: string; html: string; text?: string | null },
|
||||
audience: Audience,
|
||||
meta: { kind: "studio" | "meeting"; meetingId?: number | null; userId: number | null }
|
||||
): Promise<QueuedBroadcast | ActionError> {
|
||||
const recipients = await resolveAudience(audience);
|
||||
if (recipients.length === 0) {
|
||||
return { error: "Aucun destinataire n'a d'adresse e-mail utilisable pour cette sélection." };
|
||||
}
|
||||
if (!(await isMailConfigured())) {
|
||||
return { error: "Aucune boîte mail n'est configurée : voir Configuration › Boîte mail." };
|
||||
}
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
await queueMails(
|
||||
recipients.map((recipient) => ({
|
||||
kind: meta.kind,
|
||||
toAddress: recipient.email,
|
||||
toName: recipient.name,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text ?? null,
|
||||
replyTo: settings.association_email || null,
|
||||
meetingId: meta.meetingId ?? null,
|
||||
memberId: recipient.memberId,
|
||||
createdById: meta.userId,
|
||||
}))
|
||||
);
|
||||
|
||||
revalidatePath("/backend/boite-mail");
|
||||
return { queued: recipients.length, audience: describeAudience(audience) };
|
||||
}
|
||||
|
||||
function describeAudience(audience: Audience): string {
|
||||
if (audience.kind === "category") return "une catégorie de métier";
|
||||
if (audience.kind === "members") return "une sélection d'adhérents";
|
||||
if (audience.kind === "meeting") return "les inscrits à la rencontre";
|
||||
return "tous les adhérents actifs";
|
||||
}
|
||||
|
||||
/** Envoi de contrôle vers sa propre adresse : direct, pour un verdict immédiat. */
|
||||
export async function sendSelfTest(
|
||||
message: { subject: string; html: string; text?: string | null }
|
||||
): Promise<QueuedBroadcast | ActionError> {
|
||||
const { userId } = await requireEmails();
|
||||
const recipient = userId ? await selfRecipient(userId) : null;
|
||||
if (!recipient) return { error: "Votre compte n'a pas d'adresse e-mail utilisable." };
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const result = await sendNow({
|
||||
to: recipient.email,
|
||||
toName: recipient.name,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text ?? null,
|
||||
replyTo: settings.association_email || null,
|
||||
});
|
||||
await logSentMail({
|
||||
kind: "test",
|
||||
toAddress: recipient.email,
|
||||
subject: message.subject,
|
||||
createdById: userId,
|
||||
result: result.ok ? { ok: true } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (!result.ok) return { error: result.reason };
|
||||
return { queued: 1, audience: `votre adresse (${recipient.email})` };
|
||||
}
|
||||
|
||||
export async function sendStudioEmail(input: {
|
||||
content: GeneralEmailContent;
|
||||
audience: Audience;
|
||||
}): Promise<QueuedBroadcast | ActionError> {
|
||||
const { userId } = await requireEmails();
|
||||
const settings = await getSiteSettings();
|
||||
const base = await siteUrl();
|
||||
if (!base) {
|
||||
return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." };
|
||||
}
|
||||
|
||||
const { subject, html } = buildGeneralEmail(input.content, base, emailBrand(settings));
|
||||
return queueBroadcast({ subject, html, text: input.content.body }, input.audience, { kind: "studio", userId });
|
||||
}
|
||||
|
||||
export async function sendMeetingInvitations(input: {
|
||||
meetingId: number;
|
||||
texts: MeetingEmailTexts;
|
||||
audience: Audience;
|
||||
}): Promise<QueuedBroadcast | ActionError> {
|
||||
const { userId } = await requireEmails();
|
||||
const settings = await getSiteSettings();
|
||||
const base = await siteUrl();
|
||||
if (!base) {
|
||||
return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." };
|
||||
}
|
||||
|
||||
const [meeting] = await db.select().from(meetings).where(eq(meetings.id, input.meetingId));
|
||||
if (!meeting) return { error: "Cette rencontre n'existe plus." };
|
||||
|
||||
const [count] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(meetingRegistrations)
|
||||
.where(eq(meetingRegistrations.meetingId, meeting.id));
|
||||
|
||||
const { subject, html, registrationUrl } = buildMeetingEmail(
|
||||
{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
startsAt: meeting.startsAt.toISOString(),
|
||||
location: meeting.location,
|
||||
description: meeting.description,
|
||||
capacity: meeting.capacity,
|
||||
registered: count?.total ?? 0,
|
||||
},
|
||||
input.texts,
|
||||
base,
|
||||
emailBrand(settings)
|
||||
);
|
||||
|
||||
return queueBroadcast(
|
||||
{ subject, html, text: `${input.texts.intro}\n\n${registrationUrl}` },
|
||||
input.audience,
|
||||
{ kind: "meeting", meetingId: meeting.id, userId }
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,29 @@ import { getSession } from "@/lib/session";
|
||||
import { EmailCreator } from "@/components/EmailCreator";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
|
||||
import { audienceOptions } from "@/lib/mail-recipients";
|
||||
import { isMailConfigured } from "@/lib/mail-accounts";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EmailsPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
return <EmailCreator brand={emailBrand(settings)} />;
|
||||
const [settings, choices, mailReady, base] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
audienceOptions(),
|
||||
isMailConfigured(),
|
||||
siteUrl(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<EmailCreator
|
||||
brand={emailBrand(settings)}
|
||||
choices={choices}
|
||||
mailReady={mailReady}
|
||||
siteUrl={base}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import { meetingRegistrations, meetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
|
||||
import { audienceOptions } from "@/lib/mail-recipients";
|
||||
import { isMailConfigured } from "@/lib/mail-accounts";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -23,7 +26,8 @@ export default async function RencontresAdminPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
|
||||
const [rows, settings] = await Promise.all([
|
||||
const canSend = can(session?.user.role, "manageEmails");
|
||||
const [rows, settings, choices, mailReady, base] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
@@ -41,14 +45,11 @@ export default async function RencontresAdminPage() {
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(desc(meetings.startsAt)),
|
||||
getSiteSettings(),
|
||||
audienceOptions(),
|
||||
isMailConfigured(),
|
||||
siteUrl(),
|
||||
]);
|
||||
const emailBrand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
email: settings.association_email,
|
||||
phone: settings.association_phone,
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
const brand = emailBrand(settings);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() {
|
||||
capacity: meeting.capacity,
|
||||
registered: Number(meeting.registered),
|
||||
}}
|
||||
brand={emailBrand}
|
||||
brand={brand}
|
||||
choices={{
|
||||
...choices,
|
||||
meeting: { id: meeting.id, title: meeting.title, count: Number(meeting.registered) },
|
||||
}}
|
||||
// Un modérateur gère les rencontres mais n'écrit pas à tous les
|
||||
// adhérents : l'envoi reste réservé au studio d'e-mails.
|
||||
mailReady={canSend && mailReady}
|
||||
siteUrl={base}
|
||||
/>
|
||||
<form action={deleteMeeting} style={{ marginTop: 8 }}>
|
||||
<input type="hidden" name="id" value={meeting.id} />
|
||||
|
||||
@@ -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