mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Add Plein R email composers
This commit is contained in:
1 parent
28ada88155
commit
b2d428b08b
10 files changed
+584
-1
No files matched your search
@@ -174,7 +174,7 @@ export default async function AssociationPage() {
|
||||
const remaining = Math.max(0, meeting.capacity - registered);
|
||||
const isRegistered = myMeetingIds.has(meeting.id);
|
||||
return (
|
||||
<article key={meeting.id} className="lift" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden" }}>
|
||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
||||
<div style={{ aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }} />
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ const TITLES: Record<string, [string, string]> = {
|
||||
"Validez les offres soumises par les adhérents",
|
||||
],
|
||||
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates et les archives"],
|
||||
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
|
||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
||||
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
|
||||
@@ -148,6 +149,11 @@ export function BackendShell({
|
||||
{dotRound}Rencontres
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageEmails") && (
|
||||
<NavLink href="/backend/emails" active={pathname === "/backend/emails"}>
|
||||
{dot}E-mails
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageCategories") && (
|
||||
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
|
||||
{dotDiamond}Catégories
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { EmailCreator } from "@/components/EmailCreator";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EmailsPage() {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
||||
const settings = await getSiteSettings();
|
||||
const brand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
email: settings.association_email,
|
||||
phone: settings.association_phone,
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
|
||||
return <EmailCreator brand={brand} />;
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } 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 {
|
||||
addPastMeetingPhoto,
|
||||
createMeeting,
|
||||
@@ -56,6 +58,14 @@ export default async function RencontresAdminPage() {
|
||||
.select()
|
||||
.from(pastMeetingPhotos)
|
||||
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
|
||||
const settings = await getSiteSettings();
|
||||
const emailBrand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
email: settings.association_email,
|
||||
phone: settings.association_phone,
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
|
||||
const photosByPast = new Map<number, typeof photos>();
|
||||
for (const photo of photos) {
|
||||
@@ -131,6 +141,18 @@ export default async function RencontresAdminPage() {
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
<MeetingEmailComposer
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
startsAt: meeting.startsAt.toISOString(),
|
||||
location: meeting.location,
|
||||
description: meeting.description,
|
||||
capacity: meeting.capacity,
|
||||
registered: Number(meeting.registered),
|
||||
}}
|
||||
brand={emailBrand}
|
||||
/>
|
||||
<form action={deleteMeeting} style={{ marginTop: 8 }}>
|
||||
<input type="hidden" name="id" value={meeting.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer</button>
|
||||
|
||||
@@ -456,3 +456,87 @@ img {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---- Studio e-mail Plein R ---- */
|
||||
.email-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 370px minmax(0, 1fr);
|
||||
gap: 26px;
|
||||
align-items: start;
|
||||
}
|
||||
.email-editor-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-top: 5px solid var(--gold);
|
||||
border-radius: 16px;
|
||||
padding: 23px;
|
||||
box-shadow: 0 22px 44px -38px rgba(19, 50, 79, 0.75);
|
||||
}
|
||||
.email-editor-kicker {
|
||||
color: var(--brown);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.email-preview-shell {
|
||||
border: 1px solid #d8cdb4;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
background: #f6f2e8;
|
||||
box-shadow: 0 24px 48px -38px rgba(19, 50, 79, 0.65);
|
||||
}
|
||||
.email-preview-shell iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 760px;
|
||||
border: 0;
|
||||
background: #f6f2e8;
|
||||
}
|
||||
.email-preview-shell.compact iframe {
|
||||
height: 590px;
|
||||
}
|
||||
.email-primary-button,
|
||||
.email-secondary-button,
|
||||
.meeting-email-button {
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-primary-button {
|
||||
border: 1px solid #13324f;
|
||||
background: #13324f;
|
||||
color: #fff;
|
||||
}
|
||||
.email-secondary-button {
|
||||
border: 1px solid #d8cdb4;
|
||||
background: #fff;
|
||||
color: #5a5040;
|
||||
}
|
||||
.email-primary-button:hover { background: #1e588f; }
|
||||
.email-secondary-button:hover { border-color: #9a6638; color: #9a6638; }
|
||||
.email-primary-button:disabled,
|
||||
.email-secondary-button:disabled { opacity: 0.5; cursor: wait; }
|
||||
.meeting-email-button {
|
||||
width: 100%;
|
||||
margin-top: 9px;
|
||||
border: 1px solid #d6b66f;
|
||||
background: #fff8e8;
|
||||
color: #7a5428;
|
||||
}
|
||||
.meeting-email-button:hover { background: #f7e8bf; }
|
||||
.meeting-email-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.email-workbench,
|
||||
.meeting-email-workbench { grid-template-columns: 1fr; }
|
||||
.email-preview-shell iframe { height: 620px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
|
||||
|
||||
export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [form, setForm] = useState<GeneralEmailContent>({
|
||||
subject: `Actualités de ${brand.associationName}`,
|
||||
kicker: brand.associationName,
|
||||
title: "Votre titre ici",
|
||||
body: "Bonjour,\n\nRédigez ici votre message. Créez plusieurs paragraphes en laissant une ligne vide.\n\nLe gabarit conserve automatiquement l'identité visuelle Plein R.",
|
||||
buttonLabel: "Découvrir",
|
||||
buttonUrl: "",
|
||||
signature: `À très bientôt,\nL'équipe de ${brand.associationName}`,
|
||||
});
|
||||
const [flash, setFlash] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(window.location.origin);
|
||||
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
|
||||
}, []);
|
||||
|
||||
const email = useMemo(
|
||||
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
||||
[baseUrl, brand, form],
|
||||
);
|
||||
|
||||
function change(name: keyof GeneralEmailContent, value: string) {
|
||||
setForm((current) => ({ ...current, [name]: value }));
|
||||
}
|
||||
|
||||
function success(key: string) {
|
||||
setFlash(key);
|
||||
setTimeout(() => setFlash(""), 1800);
|
||||
}
|
||||
|
||||
async function action(key: string, task: () => Promise<void> | void) {
|
||||
try {
|
||||
setError("");
|
||||
await task();
|
||||
success(key);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Action impossible dans ce navigateur.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="email-workbench">
|
||||
<section className="email-editor-card">
|
||||
<div className="email-editor-kicker">Studio de communication</div>
|
||||
<h2 className="font-display" style={{ margin: "6px 0 8px", fontSize: 23, color: "#26201a" }}>Composer un e-mail</h2>
|
||||
<p style={{ margin: "0 0 20px", color: "#8c8068", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Logo, couleurs Plein R et coordonnées ajoutés automatiquement.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gap: 13 }}>
|
||||
<EmailField label="Objet" value={form.subject} onChange={(value) => change("subject", value)} />
|
||||
<EmailField label="Surtitre" value={form.kicker} onChange={(value) => change("kicker", value)} />
|
||||
<EmailField label="Titre" value={form.title} onChange={(value) => change("title", value)} />
|
||||
<EmailArea label="Message" value={form.body} rows={9} onChange={(value) => change("body", value)} />
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 14, display: "grid", gap: 11 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: "#6c6150" }}>Bouton facultatif</div>
|
||||
<EmailField label="Texte du bouton" value={form.buttonLabel} onChange={(value) => change("buttonLabel", value)} />
|
||||
<EmailField label="Lien" type="url" value={form.buttonUrl} onChange={(value) => change("buttonUrl", value)} />
|
||||
</div>
|
||||
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||
</div>
|
||||
|
||||
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
||||
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
|
||||
<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>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n\n${form.body}`))}>
|
||||
{flash === "copy" ? "✓ E-mail copié" : "Copier l'e-mail enrichi"}
|
||||
</button>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 9 }}>
|
||||
<button type="button" className="email-secondary-button" onClick={() => action("subject", () => navigator.clipboard.writeText(form.subject))}>
|
||||
{flash === "subject" ? "✓ Objet copié" : "Copier l'objet"}
|
||||
</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("html", () => downloadHtml(email.subject, email.html))}>
|
||||
{flash === "html" ? "✓ HTML créé" : "Télécharger HTML"}
|
||||
</button>
|
||||
</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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 10, color: "#8c8068", fontSize: 12.5 }}><strong style={{ color: "#33291d" }}>Objet :</strong> {form.subject}</div>
|
||||
<div className="email-preview-shell">
|
||||
{email ? <iframe title="Aperçu de l'e-mail" srcDoc={email.html} sandbox="" /> : <div style={{ padding: 30 }}>Préparation aperçu…</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailField({ label, value, onChange, type = "text" }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return <label className="field-label">{label}<input className="field" type={type} value={value} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6 }} /></label>;
|
||||
}
|
||||
|
||||
function EmailArea({ label, value, onChange, rows }: { label: string; value: string; onChange: (value: string) => void; rows: number }) {
|
||||
return <label className="field-label">{label}<textarea className="field" value={value} rows={rows} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6, resize: "vertical" }} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
||||
const [flash, setFlash] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
|
||||
|
||||
function show() {
|
||||
setBaseUrl(window.location.origin);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function change(name: keyof MeetingEmailTexts, value: string) {
|
||||
setTexts((current) => ({ ...current, [name]: value }));
|
||||
}
|
||||
|
||||
async function action(key: string, task: () => Promise<void> | void) {
|
||||
try {
|
||||
setError("");
|
||||
await task();
|
||||
setFlash(key);
|
||||
setTimeout(() => setFlash(""), 1800);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Action impossible dans ce navigateur.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={show} className="meeting-email-button">✦ Créer invitation e-mail</button>
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={`Invitation — ${meeting.title}`} maxWidth={1100}>
|
||||
<div className="meeting-email-workbench">
|
||||
<div style={{ display: "grid", gap: 13 }}>
|
||||
<EmailArea label="Salutation" value={texts.greeting} rows={2} onChange={(value) => change("greeting", value)} />
|
||||
<EmailArea label="Introduction" value={texts.intro} rows={5} onChange={(value) => change("intro", value)} />
|
||||
<EmailArea label="Conclusion" value={texts.outro} rows={4} onChange={(value) => change("outro", value)} />
|
||||
<EmailArea label="Signature" value={texts.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
|
||||
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
||||
</p>
|
||||
{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"))}>
|
||||
{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}`))}>
|
||||
{flash === "copy" ? "✓ Invitation copiée" : "Copier invitation enrichie"}
|
||||
</button>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("link", () => navigator.clipboard.writeText(email.registrationUrl))}>{flash === "link" ? "✓ Lien copié" : "Copier lien"}</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("html", () => downloadHtml(email.subject, email.html, "invitation"))}>{flash === "html" ? "✓ HTML créé" : "HTML"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 9, color: "#8c8068", fontSize: 12 }}><strong style={{ color: "#33291d" }}>Objet :</strong> {email?.subject}</div>
|
||||
<div className="email-preview-shell compact">
|
||||
{email && <iframe title="Aperçu invitation" srcDoc={email.html} sandbox="" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailArea({ label, value, onChange, rows }: { label: string; value: string; onChange: (value: string) => void; rows: number }) {
|
||||
return <label className="field-label">{label}<textarea className="field" value={value} rows={rows} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6, resize: "vertical" }} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
function bytesBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
function utf8Base64(value: string) {
|
||||
return bytesBase64(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
function wrapBase64(value: string) {
|
||||
return value.match(/.{1,76}/g)?.join("\r\n") ?? "";
|
||||
}
|
||||
|
||||
function slug(value: string) {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase() || "email-plein-r";
|
||||
}
|
||||
|
||||
function emailBody(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html").body.innerHTML.trim();
|
||||
}
|
||||
|
||||
export async function copyRichEmail(html: string, plainText: string) {
|
||||
const holder = document.createElement("div");
|
||||
holder.contentEditable = "true";
|
||||
holder.setAttribute("aria-hidden", "true");
|
||||
holder.style.cssText = "position:fixed;left:-10000px;top:0;width:700px;background:#F6F2E8;pointer-events:none;z-index:-1";
|
||||
holder.innerHTML = emailBody(html);
|
||||
document.body.appendChild(holder);
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
try {
|
||||
holder.focus();
|
||||
range.selectNodeContents(holder);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
if (!document.execCommand("copy")) throw new Error("Copie refusée");
|
||||
} catch (error) {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") throw error;
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([emailBody(html)], { type: "text/html" }),
|
||||
"text/plain": new Blob([plainText], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
selection?.removeAllRanges();
|
||||
holder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadOutlookDraft(subject: string, html: string, prefix = "email") {
|
||||
const logoUrl = html.match(/\bsrc=["']([^"']*\/assets\/logo\.png(?:\?[^"']*)?)["']/i)?.[1];
|
||||
let renderedHtml = html;
|
||||
let logoPart = "";
|
||||
const boundary = "----=_PleinR_Outlook_Draft";
|
||||
|
||||
if (logoUrl) {
|
||||
const response = await fetch(logoUrl, { cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("Logo Plein R introuvable.");
|
||||
const base64 = bytesBase64(new Uint8Array(await response.arrayBuffer()));
|
||||
renderedHtml = html.replace(/(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi, "$1$2cid:plein-r-logo$2");
|
||||
logoPart = [`--${boundary}`, "Content-Type: image/png; name=\"logo.png\"", "Content-Transfer-Encoding: base64", "Content-ID: <plein-r-logo>", "Content-Disposition: inline; filename=\"logo.png\"", "", wrapBase64(base64)].join("\r\n");
|
||||
}
|
||||
|
||||
const mime = logoPart
|
||||
? ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", `Content-Type: multipart/related; type=\"text/html\"; boundary=\"${boundary}\"`, "", `--${boundary}`, "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(renderedHtml)), logoPart, `--${boundary}--`, ""].join("\r\n")
|
||||
: ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(html)), ""].join("\r\n");
|
||||
downloadBlob(new Blob([mime], { type: "message/rfc822" }), `${prefix}-${slug(subject)}-outlook.eml`);
|
||||
}
|
||||
|
||||
export function downloadHtml(subject: string, html: string, prefix = "email") {
|
||||
downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), `${prefix}-${slug(subject)}.html`);
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export type EmailBrand = {
|
||||
associationName: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
siret?: string;
|
||||
};
|
||||
|
||||
export type GeneralEmailContent = {
|
||||
subject: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
body: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type MeetingEmailTexts = {
|
||||
greeting: string;
|
||||
intro: string;
|
||||
outro: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type MeetingEmailData = {
|
||||
id: number;
|
||||
title: string;
|
||||
startsAt: string;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
capacity: number;
|
||||
registered: number;
|
||||
};
|
||||
|
||||
function esc(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function lines(value: unknown) {
|
||||
return esc(value).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
|
||||
function webUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function paragraphs(value: string) {
|
||||
return value
|
||||
.split(/\r?\n\s*\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(paragraph, index, all) =>
|
||||
`<tr><td style="padding-bottom:${index === all.length - 1 ? 0 : 18}px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5d5447;">${lines(paragraph)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function footer(brand: EmailBrand) {
|
||||
const contact = [brand.email, brand.phone].filter(Boolean).map(esc).join(" · ");
|
||||
return `<tr><td bgcolor="#13324F" style="background:#13324F;padding:25px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:20px;color:#C9D8E5;">
|
||||
<strong style="color:#FFFFFF;">${esc(brand.associationName)}</strong>${brand.address ? ` · ${lines(brand.address)}` : ""}<br>
|
||||
${contact}${brand.siret ? `${contact ? "<br>" : ""}SIRET : ${esc(brand.siret)}` : ""}
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function emailShell({ title, content, baseUrl, brand }: { title: string; content: string; baseUrl: string; brand: EmailBrand }) {
|
||||
const logoUrl = `${baseUrl.replace(/\/$/, "")}/assets/logo.png`;
|
||||
return `<!doctype html>
|
||||
<html lang="fr" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title>
|
||||
<!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
||||
</head>
|
||||
<body bgcolor="#F6F2E8" style="margin:0;padding:0;background:#F6F2E8;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F6F2E8" style="width:100%;background:#F6F2E8;border-collapse:collapse;">
|
||||
<tr><td align="center" style="padding:36px 14px;">
|
||||
<!--[if mso]><table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background:#FFFFFF;border:1px solid #E6DCC6;border-collapse:collapse;">
|
||||
<tr><td bgcolor="#13324F" style="background:#13324F;padding:22px 40px;border-bottom:5px solid #E0A63C;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td width="64" valign="middle"><span style="display:block;background:#F6F2E8;padding:6px;border-radius:10px;"><img src="${esc(logoUrl)}" width="52" alt="${esc(brand.associationName)}" style="display:block;width:52px;height:auto;"></span></td>
|
||||
<td valign="middle" style="padding-left:17px;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:bold;color:#FFFFFF;">${esc(brand.associationName)}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
${content}
|
||||
${footer(brand)}
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr></table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function cta(url: string, label: string) {
|
||||
const safe = webUrl(url);
|
||||
if (!safe || !label) return "";
|
||||
return `<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;"><tr><td align="center" bgcolor="#2C6FB3" style="background:#2C6FB3;padding:16px 0;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:17px;font-weight:bold;"><a href="${esc(safe)}" target="_blank" style="display:block;color:#FFFFFF;text-decoration:none;">${esc(label)}</a></td></tr></table>`;
|
||||
}
|
||||
|
||||
export function buildGeneralEmail(content: GeneralEmailContent, baseUrl: string, brand: EmailBrand) {
|
||||
const button = cta(content.buttonUrl, content.buttonLabel);
|
||||
const body = `
|
||||
<tr><td style="padding:42px 40px 16px;">
|
||||
${content.kicker ? `<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">${esc(content.kicker)}</div>` : ""}
|
||||
<div style="padding-bottom:23px;font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:36px;color:#26201A;">${esc(content.title)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${paragraphs(content.body)}</table>
|
||||
</td></tr>
|
||||
${button ? `<tr><td align="center" style="padding:22px 40px 8px;">${button}</td></tr>` : ""}
|
||||
<tr><td style="padding:25px 40px 40px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:25px;color:#26201A;">${lines(content.signature)}</td></tr>`;
|
||||
return { subject: content.subject, html: emailShell({ title: content.subject, content: body, baseUrl, brand }) };
|
||||
}
|
||||
|
||||
export function defaultMeetingEmailTexts(brand: EmailBrand): MeetingEmailTexts {
|
||||
return {
|
||||
greeting: "Chère adhérente, cher adhérent,",
|
||||
intro: "Plein R a le plaisir de vous convier à sa prochaine rencontre. Un moment pour créer des liens, partager les actualités locales et faire grandir notre réseau.",
|
||||
outro: "Les places sont limitées : pensez à confirmer votre présence dès maintenant.",
|
||||
signature: `À très bientôt,\nL'équipe de ${brand.associationName}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmailTexts, baseUrl: string, brand: EmailBrand) {
|
||||
const date = new Date(meeting.startsAt);
|
||||
const dateLong = new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/association#rencontre-${meeting.id}`;
|
||||
const subject = `Invitation Plein R — ${meeting.title} · ${dateLong}`;
|
||||
const button = cta(registrationUrl, "Je m'inscris à la rencontre");
|
||||
const body = `
|
||||
<tr><td style="padding:42px 40px 18px;">
|
||||
<div style="padding-bottom:16px;font-family:Arial,Helvetica,sans-serif;font-size:11px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">Invitation · Rencontre Plein R</div>
|
||||
${texts.greeting ? `<div style="padding-bottom:15px;font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:25px;color:#26201A;">${lines(texts.greeting)}</div>` : ""}
|
||||
${texts.intro ? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(texts.intro)}</div>` : ""}
|
||||
</td></tr>
|
||||
<tr><td style="padding:8px 40px 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF7EF" style="width:100%;background:#FAF7EF;border:1px solid #E6DCC6;border-left:5px solid #E0A63C;border-collapse:collapse;">
|
||||
<tr><td style="padding:27px 28px;">
|
||||
<div style="padding-bottom:11px;font-family:Georgia,'Times New Roman',serif;font-size:25px;line-height:31px;color:#26201A;">${esc(meeting.title)}</div>
|
||||
${meeting.description ? `<div style="padding-bottom:17px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;color:#5D5447;">${lines(meeting.description)}</div>` : ""}
|
||||
<div style="border-top:1px solid #E6DCC6;padding-top:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:25px;color:#5D5447;">
|
||||
<strong style="color:#26201A;">${esc(dateLong.charAt(0).toUpperCase() + dateLong.slice(1))}</strong><br>
|
||||
${meeting.location ? `${esc(meeting.location)}<br>` : ""}
|
||||
${remaining > 0 ? `${remaining} place${remaining > 1 ? "s" : ""} restante${remaining > 1 ? "s" : ""}` : "Rencontre complète"}
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:28px 40px 12px;">${button}</td></tr>
|
||||
<tr><td style="padding:14px 40px 40px;">
|
||||
${texts.outro ? `<div style="padding-bottom:22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(texts.outro)}</div>` : ""}
|
||||
${texts.signature ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:25px;color:#26201A;">${lines(texts.signature)}</div>` : ""}
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`;
|
||||
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), registrationUrl };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Capability =
|
||||
| "manageAdmins"
|
||||
| "manageCategories"
|
||||
| "manageMeetings"
|
||||
| "manageEmails"
|
||||
| "manageSettings"
|
||||
| "memberSpace";
|
||||
|
||||
@@ -32,6 +33,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
"manageAdmins",
|
||||
"manageCategories",
|
||||
"manageMeetings",
|
||||
"manageEmails",
|
||||
"manageSettings",
|
||||
"memberSpace",
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user