From 36a7f57d6ac3960442e52d91b886e1f341d25c5a Mon Sep 17 00:00:00 2001 From: Mathieu Mure Date: Sat, 28 Feb 2026 09:24:13 +0100 Subject: [PATCH] feat: add admin emails page to help sending emails --- app/admin/mail/page.module.css | 179 +++++++++++++++++++ app/admin/mail/page.tsx | 141 +++++++++++++++ app/api/mail/route.ts | 28 +++ data/mail-templates.ts | 10 ++ data/templates/speaker-accepted-talk.ts | 22 +++ data/templates/speaker-invitation-to-talk.ts | 23 +++ data/templates/sponsor-recontact.ts | 24 +++ data/templates/types.ts | 12 ++ data/templates/utils.ts | 41 +++++ modules/icons/ExternalLink.tsx | 24 +++ 10 files changed, 504 insertions(+) create mode 100644 app/admin/mail/page.module.css create mode 100644 app/admin/mail/page.tsx create mode 100644 app/api/mail/route.ts create mode 100644 data/mail-templates.ts create mode 100644 data/templates/speaker-accepted-talk.ts create mode 100644 data/templates/speaker-invitation-to-talk.ts create mode 100644 data/templates/sponsor-recontact.ts create mode 100644 data/templates/types.ts create mode 100644 data/templates/utils.ts create mode 100644 modules/icons/ExternalLink.tsx diff --git a/app/admin/mail/page.module.css b/app/admin/mail/page.module.css new file mode 100644 index 00000000..70823af7 --- /dev/null +++ b/app/admin/mail/page.module.css @@ -0,0 +1,179 @@ +.container { + max-width: 500px; + margin: 2rem auto; + padding: 0 1rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 2rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.field label { + font-weight: 500; + color: var(--font-color-strong); +} + +.field input, +.field select { + padding: 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); + background-color: var(--background-button); + color: var(--font-color); + font-size: 1rem; +} + +.field input:focus, +.field select:focus { + outline: 2px solid var(--main-color); + outline-offset: 2px; +} + +.button { + padding: 0.75rem 1.25rem; + border-radius: 0.375rem; + border: none; + background-color: var(--main-color); + color: var(--background-page); + font-weight: 500; + font-size: 1rem; + cursor: pointer; + transition: background-color 100ms; +} + +.button:hover:not(:disabled) { + background-color: var(--main-color-dark); +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.formActions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.buttonSecondary { + padding: 0.75rem 1.25rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); + background-color: var(--background-button); + color: var(--font-color); + font-weight: 500; + font-size: 1rem; + cursor: pointer; + transition: background-color 100ms; +} + +.buttonSecondary:hover:not(:disabled) { + background-color: var(--background-button-hover); +} + +.buttonSecondary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.buttonIcon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem; + border-radius: 0.375rem; + border: none; + background-color: var(--main-color); + color: var(--background-page); + cursor: pointer; + transition: background-color 100ms; +} + +.buttonIcon:hover:not(:disabled) { + background-color: var(--main-color-dark); +} + +.buttonIcon:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.buttonIcon svg { + margin-left: 1ch; +} + +.previewSection { + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.previewField { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.previewLabel { + font-weight: 500; + color: var(--font-color-strong); +} + +.previewZone { + position: relative; + padding: 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); + background-color: var(--background-button); + color: var(--font-color); +} + +.previewZone:hover .copyButton { + opacity: 1; +} + +.previewSubjectText, +.previewBodyText { + margin: 0; + font-family: inherit; + font-size: 0.9375rem; +} + +.previewBodyText { + white-space: pre-wrap; +} + +.copyButton { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); + background-color: var(--background-page); + color: var(--font-color); + font-size: 0.8125rem; + cursor: pointer; + opacity: 0; + transition: opacity 100ms; +} + +.copyButton:hover { + background-color: var(--background-button-hover); +} diff --git a/app/admin/mail/page.tsx b/app/admin/mail/page.tsx new file mode 100644 index 00000000..c7ad0aa3 --- /dev/null +++ b/app/admin/mail/page.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { ComponentProps, useState } from 'react'; +import { Check } from '../../../modules/icons/Check'; +import { Copy } from '../../../modules/icons/Copy'; +import { ExternalLink } from '../../../modules/icons/ExternalLink'; +import styles from './page.module.css'; +import { mailTemplates } from '../../../data/mail-templates'; + +const TEMPLATES = Object.keys(mailTemplates); + +function buildParams(template: string, contactName: string, talkTitle: string): URLSearchParams { + const params = new URLSearchParams({ template }); + if (contactName) params.set('contactName', contactName); + if (talkTitle) params.set('talkTitle', talkTitle); + return params; +} + +function toURI(map: Record): string { + return Object.entries(map) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); +} + +function CopyableZone({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( +
+ {label} +
+
{value}
+ +
+
+ ); +} + +export default function AdminMail() { + const [template, setTemplate] = useState(TEMPLATES[0]); + const [contactName, setContactName] = useState(''); + const [talkTitle, setTalkTitle] = useState(''); + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [showPreview, setShowPreview] = useState(false); + + const handlePreview = async () => { + setPreviewLoading(true); + const params = buildParams(template, contactName, talkTitle); + const response = await fetch(`/api/mail?${params}`); + const data = await response.json(); + if (response.ok) { + setSubject(data.subject); + setBody(data.body); + setShowPreview(true); + } + setPreviewLoading(false); + }; + + const handleSubmit: ComponentProps<'form'>['onSubmit'] = async (e) => { + e.preventDefault(); + setLoading(true); + const params = buildParams(template, contactName, talkTitle); + const response = await fetch(`/api/mail?${params}`); + const data = await response.json(); + if (response.ok) { + const mailToParams = { + cc: 'contact@lyonjs.org', + subject: data.subject, + body: data.body, + }; + window.location.href = `mailto:${encodeURIComponent(email)}?${toURI(mailToParams)}`; + } + setLoading(false); + }; + + return ( +
+

Générateur de mail

+
+
+ + +
+ +
+ + setEmail(e.target.value)} required /> +
+ +
+ + setContactName(e.target.value)} /> +
+ +
+ + setTalkTitle(e.target.value)} /> +
+ +
+ + +
+
+ + {showPreview && ( +
+ + +
+ )} +
+ ); +} diff --git a/app/api/mail/route.ts b/app/api/mail/route.ts new file mode 100644 index 00000000..c2f48995 --- /dev/null +++ b/app/api/mail/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { mailTemplates } from '../../../data/mail-templates'; +import { schedule } from '../../../data/schedule'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const templateName = searchParams.get('template'); + const talkTitle = searchParams.get('talkTitle') ?? undefined; + const contactName = searchParams.get('contactName') ?? undefined; + + const template = templateName ? mailTemplates[templateName] : undefined; + + if (!template) { + return NextResponse.json({ error: `${templateName} not found` }, { status: 404 }); + } + + const subject = + typeof template.subject === 'string' + ? template.subject + : template.subject({ talkTitle, contactName, dates: schedule }); + const body = + typeof template.body === 'string' ? template.body : template.body({ talkTitle, contactName, dates: schedule }); + + return NextResponse.json({ + subject, + body, + }); +} diff --git a/data/mail-templates.ts b/data/mail-templates.ts new file mode 100644 index 00000000..8d2f2cb1 --- /dev/null +++ b/data/mail-templates.ts @@ -0,0 +1,10 @@ +import { speakerAcceptedTalk } from './templates/speaker-accepted-talk'; +import { type MailTemplate } from './templates/types'; +import { speakerInvitationToTalk } from './templates/speaker-invitation-to-talk'; +import { sponsorRecontact } from './templates/sponsor-recontact'; + +export const mailTemplates: Record = { + speakerAcceptedTalk, + speakerInvitationToTalk, + sponsorRecontact, +}; diff --git a/data/templates/speaker-accepted-talk.ts b/data/templates/speaker-accepted-talk.ts new file mode 100644 index 00000000..5b30ed25 --- /dev/null +++ b/data/templates/speaker-accepted-talk.ts @@ -0,0 +1,22 @@ +import { MailTemplate } from './types'; +import { addIfDefined, datesAfterToday } from './utils'; + +export const speakerAcceptedTalk: MailTemplate = { + subject: ({ talkTitle }) => `LyonJS – 🎉 On programme ton talk${addIfDefined(talkTitle, { pre: ' [', post: ']' })}`, + body: ({ contactName, dates }) => `Hello${addIfDefined(contactName, { pre: ' ' })}, + +Merci encore pour ta proposition 🙌 + +On a beaucoup aimé ton sujet et on serait très heureux de le programmer lors d’un prochain LyonJS 🎉 + +Parmi les dates suivantes, quelles sont celles où tu serais disponible ? +${datesAfterToday(dates)} + +On verra ensuite ensemble les détails logistiques et ce dont tu as besoin pour être à l’aise. + +Hâte de t’accueillir sur scène 🚀 + +L’équipe LyonJS + +`, +}; diff --git a/data/templates/speaker-invitation-to-talk.ts b/data/templates/speaker-invitation-to-talk.ts new file mode 100644 index 00000000..1cafe703 --- /dev/null +++ b/data/templates/speaker-invitation-to-talk.ts @@ -0,0 +1,23 @@ +import { MailTemplate } from './types'; +import { addIfDefined } from './utils'; + +export const speakerInvitationToTalk: MailTemplate = { + subject: `Ça te dirait de parler à LyonJS ? 🎤`, + body: ({ contactName }) => `Hello${addIfDefined(contactName)}, + +On organise régulièrement des meetups à Lyon autour de JavaScript et de l’écosystème Web, et on serait ravi·e·s de t’avoir parmi nos speaker·euse·s ! + +Si ça te tente, tu peux proposer un sujet via notre CFP ici : + +👉 https://conference-hall.io/lyon-js-meetup + +Les formats sont assez libres (retour d’expérience, deep dive technique, démo, etc.), et on peut bien sûr t’accompagner si c’est une première ou si tu veux échanger sur l’angle du talk 🙂 + +N’hésite pas si tu as des questions ! + +Au plaisir d’échanger, + +L’équipe LyonJS + +`, +}; diff --git a/data/templates/sponsor-recontact.ts b/data/templates/sponsor-recontact.ts new file mode 100644 index 00000000..242839ac --- /dev/null +++ b/data/templates/sponsor-recontact.ts @@ -0,0 +1,24 @@ +import { MailTemplate } from './types'; +import { addIfDefined, datesWithNoSponsor } from './utils'; + +export const sponsorRecontact: MailTemplate = { + subject: `LyonJS – Prochain événement ?`, + body: ({ contactName, dates }) => `Hello${addIfDefined(contactName, { pre: ' ' })}, + +Encore un grand merci pour votre soutien à LyonJS 💛 Sans vous, la communauté ne pourrait pas fonctionner comme aujourd’hui. + +On commence à planifier les prochains événements et on voudrait savoir si vous seriez partants pour en accueillir un de nouveau. + +Voici les dates envisagées : +${datesWithNoSponsor(dates)} + + +Dites-nous si l’une d’elles vous conviendrait, ce serait un plaisir de collaborer à nouveau 🙂 + +Merci encore pour votre confiance 🙌 + +L’équipe LyonJS + + +`, +}; diff --git a/data/templates/types.ts b/data/templates/types.ts new file mode 100644 index 00000000..6fafb9ca --- /dev/null +++ b/data/templates/types.ts @@ -0,0 +1,12 @@ +import { Schedule } from '../../modules/schedule/types'; + +export type MailTemplateParams = { + talkTitle?: string; + contactName?: string; + dates: Array; +}; + +export type MailTemplate = { + subject: string | ((params: MailTemplateParams) => string); + body: string | ((params: MailTemplateParams) => string); +}; diff --git a/data/templates/utils.ts b/data/templates/utils.ts new file mode 100644 index 00000000..2edfa45d --- /dev/null +++ b/data/templates/utils.ts @@ -0,0 +1,41 @@ +import { Temporal } from 'temporal-polyfill'; +import { Schedule } from '../../modules/schedule/types'; + +function formatDate(iso: string): string { + const [month, day, year] = iso.split('/'); + const plainDate = Temporal.PlainDate.from({ year: parseInt(year), month: parseInt(month), day: parseInt(day) }); + return plainDate.toLocaleString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); +} +type Options = { + pre?: string; + post?: string; +}; +export function addIfDefined(value?: string, options?: Options): string { + if (!value) { + return ''; + } + return `${options?.pre || ''}${value}${options?.post || ''}`; +} + +export function datesWithNoSponsor(dates: Array): string { + return dates + .filter((item) => !item.sponsor) + .map((item) => `\t- ${formatDate(item.date)}`) + .join('\n'); +} + +export function datesAfterToday(dates: Array): string { + const today = Temporal.Now.plainDateISO(); + return dates + .filter((item) => { + const [month, day, year] = item.date.split('/'); + const itemDate = Temporal.PlainDate.from({ year: parseInt(year), month: parseInt(month), day: parseInt(day) }); + return Temporal.PlainDate.compare(itemDate, today) > 0; + }) + .map((item) => `\t- ${formatDate(item.date)}`) + .join('\n'); +} diff --git a/modules/icons/ExternalLink.tsx b/modules/icons/ExternalLink.tsx new file mode 100644 index 00000000..e336f951 --- /dev/null +++ b/modules/icons/ExternalLink.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { IconProps } from './types'; + +export const ExternalLink: React.FC = ({ color = 'currentColor', size = 16 }) => ( + +);