mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 15:18:04 +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,
|
saveOAuthApp,
|
||||||
saveSmtpAccount,
|
saveSmtpAccount,
|
||||||
setActiveMailProvider,
|
setActiveMailProvider,
|
||||||
|
isMailConfigured,
|
||||||
} from "@/lib/mail-accounts";
|
} from "@/lib/mail-accounts";
|
||||||
import type { MailProvider } from "@/db/schema";
|
import type { MailProvider } from "@/db/schema";
|
||||||
import { sendNow } from "@/lib/mailer";
|
import { sendNow } from "@/lib/mailer";
|
||||||
import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox";
|
import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox";
|
||||||
import { activeMemberRecipients, selfRecipient } from "@/lib/mail-recipients";
|
import { activeMemberRecipients, resolveAudience, selfRecipient, type Audience } from "@/lib/mail-recipients";
|
||||||
import { buildCredentialsEmail, buildGeneralEmail, buildInformationEmail } from "@/lib/email-templates";
|
import {
|
||||||
|
buildCredentialsEmail,
|
||||||
|
buildGeneralEmail,
|
||||||
|
buildInformationEmail,
|
||||||
|
buildMeetingEmail,
|
||||||
|
type GeneralEmailContent,
|
||||||
|
type MeetingEmailTexts,
|
||||||
|
} from "@/lib/email-templates";
|
||||||
import { richTextToPlain } from "@/lib/rich-text";
|
import { richTextToPlain } from "@/lib/rich-text";
|
||||||
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
|
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
|
||||||
import { siteUrl } from "@/lib/social-accounts";
|
import { siteUrl } from "@/lib/social-accounts";
|
||||||
@@ -1774,3 +1782,146 @@ export async function cancelMail(formData: FormData) {
|
|||||||
if (id) await cancelMailMessage(id);
|
if (id) await cancelMailMessage(id);
|
||||||
revalidatePath("/backend/boite-mail");
|
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 { EmailCreator } from "@/components/EmailCreator";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { emailBrand, 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";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function EmailsPage() {
|
export default async function EmailsPage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
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 { ImageField } from "@/components/ImageField";
|
||||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||||
import { can } from "@/lib/rbac";
|
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";
|
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,7 +26,8 @@ export default async function RencontresAdminPage() {
|
|||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
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
|
db
|
||||||
.select({
|
.select({
|
||||||
id: meetings.id,
|
id: meetings.id,
|
||||||
@@ -41,14 +45,11 @@ export default async function RencontresAdminPage() {
|
|||||||
.groupBy(meetings.id)
|
.groupBy(meetings.id)
|
||||||
.orderBy(desc(meetings.startsAt)),
|
.orderBy(desc(meetings.startsAt)),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
|
audienceOptions(),
|
||||||
|
isMailConfigured(),
|
||||||
|
siteUrl(),
|
||||||
]);
|
]);
|
||||||
const emailBrand = {
|
const brand = emailBrand(settings);
|
||||||
associationName: settings.association_name,
|
|
||||||
address: settings.association_address,
|
|
||||||
email: settings.association_email,
|
|
||||||
phone: settings.association_phone,
|
|
||||||
siret: settings.association_siret,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gap: 28 }}>
|
<div style={{ display: "grid", gap: 28 }}>
|
||||||
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() {
|
|||||||
capacity: meeting.capacity,
|
capacity: meeting.capacity,
|
||||||
registered: Number(meeting.registered),
|
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 }}>
|
<form action={deleteMeeting} style={{ marginTop: 8 }}>
|
||||||
<input type="hidden" name="id" value={meeting.id} />
|
<input type="hidden" name="id" value={meeting.id} />
|
||||||
|
|||||||
@@ -3,9 +3,32 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||||
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
|
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 }) {
|
export function EmailCreator({
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
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>({
|
const [form, setForm] = useState<GeneralEmailContent>({
|
||||||
subject: `Actualités de ${brand.associationName}`,
|
subject: `Actualités de ${brand.associationName}`,
|
||||||
kicker: brand.associationName,
|
kicker: brand.associationName,
|
||||||
@@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBaseUrl(window.location.origin);
|
// L'adresse enregistrée prime : c'est elle que verront les destinataires,
|
||||||
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
|
// 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(
|
const email = useMemo(
|
||||||
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
||||||
[baseUrl, brand, form],
|
[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) {
|
function change(name: keyof GeneralEmailContent, value: string) {
|
||||||
setForm((current) => ({ ...current, [name]: value }));
|
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)} />
|
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||||
</div>
|
</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>}
|
{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))}>
|
<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"}
|
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
<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>
|
</p>
|
||||||
</section>
|
</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 { Modal } from "@/components/Modal";
|
||||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||||
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
|
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 [open, setOpen] = useState(false);
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState(siteUrl);
|
||||||
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
||||||
const [flash, setFlash] = useState("");
|
const [flash, setFlash] = useState("");
|
||||||
const [error, setError] = 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 email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
|
||||||
|
const recipientCount = audienceCount(audience, choices);
|
||||||
|
|
||||||
function show() {
|
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);
|
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) {
|
function change(name: keyof MeetingEmailTexts, value: string) {
|
||||||
setTexts((current) => ({ ...current, [name]: value }));
|
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 }}>
|
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
|
||||||
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
||||||
</p>
|
</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>}
|
{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"}
|
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
|
<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