diff --git a/src/app/association/page.tsx b/src/app/association/page.tsx index 428aba0..ae617bd 100644 --- a/src/app/association/page.tsx +++ b/src/app/association/page.tsx @@ -174,7 +174,7 @@ export default async function AssociationPage() { const remaining = Math.max(0, meeting.capacity - registered); const isRegistered = myMeetingIds.has(meeting.id); return ( -
+
diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index b7869c4..aa94198 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -16,6 +16,7 @@ const TITLES: Record = { "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 )} + {can(user.role, "manageEmails") && ( + + {dot}E-mails + + )} {can(user.role, "manageCategories") && ( {dotDiamond}Catégories diff --git a/src/app/backend/emails/page.tsx b/src/app/backend/emails/page.tsx new file mode 100644 index 0000000..398d038 --- /dev/null +++ b/src/app/backend/emails/page.tsx @@ -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 ; +} diff --git a/src/app/backend/rencontres/page.tsx b/src/app/backend/rencontres/page.tsx index 541497e..c4e6fb4 100644 --- a/src/app/backend/rencontres/page.tsx +++ b/src/app/backend/rencontres/page.tsx @@ -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(); for (const photo of photos) { @@ -131,6 +141,18 @@ export default async function RencontresAdminPage() {
+
diff --git a/src/app/globals.css b/src/app/globals.css index ee9d017..c3266ab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } +} + diff --git a/src/components/EmailCreator.tsx b/src/components/EmailCreator.tsx new file mode 100644 index 0000000..c4069cf --- /dev/null +++ b/src/components/EmailCreator.tsx @@ -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({ + 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) { + try { + setError(""); + await task(); + success(key); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Action impossible dans ce navigateur."); + } + } + + return ( +
+
+
Studio de communication
+

Composer un e-mail

+

+ Logo, couleurs Plein R et coordonnées ajoutés automatiquement. +

+ +
+ change("subject", value)} /> + change("kicker", value)} /> + change("title", value)} /> + change("body", value)} /> +
+
Bouton facultatif
+ change("buttonLabel", value)} /> + change("buttonUrl", value)} /> +
+ change("signature", value)} /> +
+ + {error &&
{error}
} +
+ + +
+ + +
+
+

+ Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires. +

+
+ +
+
Objet : {form.subject}
+
+ {email ?