Skip to content

Commit dada9fc

Browse files
authored
feat: add admin emails page to help sending emails (#620)
1 parent 5b402e2 commit dada9fc

10 files changed

Lines changed: 504 additions & 0 deletions

File tree

app/admin/mail/page.module.css

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
.container {
2+
max-width: 500px;
3+
margin: 2rem auto;
4+
padding: 0 1rem;
5+
}
6+
7+
.form {
8+
display: flex;
9+
flex-direction: column;
10+
gap: 1.5rem;
11+
margin-top: 2rem;
12+
}
13+
14+
.field {
15+
display: flex;
16+
flex-direction: column;
17+
gap: 0.5rem;
18+
}
19+
20+
.field label {
21+
font-weight: 500;
22+
color: var(--font-color-strong);
23+
}
24+
25+
.field input,
26+
.field select {
27+
padding: 0.75rem;
28+
border-radius: 0.375rem;
29+
border: 1px solid var(--border-color);
30+
background-color: var(--background-button);
31+
color: var(--font-color);
32+
font-size: 1rem;
33+
}
34+
35+
.field input:focus,
36+
.field select:focus {
37+
outline: 2px solid var(--main-color);
38+
outline-offset: 2px;
39+
}
40+
41+
.button {
42+
padding: 0.75rem 1.25rem;
43+
border-radius: 0.375rem;
44+
border: none;
45+
background-color: var(--main-color);
46+
color: var(--background-page);
47+
font-weight: 500;
48+
font-size: 1rem;
49+
cursor: pointer;
50+
transition: background-color 100ms;
51+
}
52+
53+
.button:hover:not(:disabled) {
54+
background-color: var(--main-color-dark);
55+
}
56+
57+
.button:disabled {
58+
opacity: 0.6;
59+
cursor: not-allowed;
60+
}
61+
62+
.formActions {
63+
display: flex;
64+
align-items: center;
65+
gap: 0.75rem;
66+
}
67+
68+
.buttonSecondary {
69+
padding: 0.75rem 1.25rem;
70+
border-radius: 0.375rem;
71+
border: 1px solid var(--border-color);
72+
background-color: var(--background-button);
73+
color: var(--font-color);
74+
font-weight: 500;
75+
font-size: 1rem;
76+
cursor: pointer;
77+
transition: background-color 100ms;
78+
}
79+
80+
.buttonSecondary:hover:not(:disabled) {
81+
background-color: var(--background-button-hover);
82+
}
83+
84+
.buttonSecondary:disabled {
85+
opacity: 0.6;
86+
cursor: not-allowed;
87+
}
88+
89+
.buttonIcon {
90+
display: inline-flex;
91+
align-items: center;
92+
justify-content: center;
93+
padding: 0.75rem;
94+
border-radius: 0.375rem;
95+
border: none;
96+
background-color: var(--main-color);
97+
color: var(--background-page);
98+
cursor: pointer;
99+
transition: background-color 100ms;
100+
}
101+
102+
.buttonIcon:hover:not(:disabled) {
103+
background-color: var(--main-color-dark);
104+
}
105+
106+
.buttonIcon:disabled {
107+
opacity: 0.6;
108+
cursor: not-allowed;
109+
}
110+
111+
.buttonIcon svg {
112+
margin-left: 1ch;
113+
}
114+
115+
.previewSection {
116+
margin-top: 2.5rem;
117+
padding-top: 1.5rem;
118+
border-top: 1px solid var(--border-color);
119+
display: flex;
120+
flex-direction: column;
121+
gap: 1.25rem;
122+
}
123+
124+
.previewField {
125+
display: flex;
126+
flex-direction: column;
127+
gap: 0.5rem;
128+
}
129+
130+
.previewLabel {
131+
font-weight: 500;
132+
color: var(--font-color-strong);
133+
}
134+
135+
.previewZone {
136+
position: relative;
137+
padding: 0.75rem;
138+
border-radius: 0.375rem;
139+
border: 1px solid var(--border-color);
140+
background-color: var(--background-button);
141+
color: var(--font-color);
142+
}
143+
144+
.previewZone:hover .copyButton {
145+
opacity: 1;
146+
}
147+
148+
.previewSubjectText,
149+
.previewBodyText {
150+
margin: 0;
151+
font-family: inherit;
152+
font-size: 0.9375rem;
153+
}
154+
155+
.previewBodyText {
156+
white-space: pre-wrap;
157+
}
158+
159+
.copyButton {
160+
position: absolute;
161+
top: 0.5rem;
162+
right: 0.5rem;
163+
display: inline-flex;
164+
align-items: center;
165+
gap: 0.25rem;
166+
padding: 0.375rem 0.625rem;
167+
border-radius: 0.375rem;
168+
border: 1px solid var(--border-color);
169+
background-color: var(--background-page);
170+
color: var(--font-color);
171+
font-size: 0.8125rem;
172+
cursor: pointer;
173+
opacity: 0;
174+
transition: opacity 100ms;
175+
}
176+
177+
.copyButton:hover {
178+
background-color: var(--background-button-hover);
179+
}

app/admin/mail/page.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use client';
2+
3+
import { ComponentProps, useState } from 'react';
4+
import { Check } from '../../../modules/icons/Check';
5+
import { Copy } from '../../../modules/icons/Copy';
6+
import { ExternalLink } from '../../../modules/icons/ExternalLink';
7+
import styles from './page.module.css';
8+
import { mailTemplates } from '../../../data/mail-templates';
9+
10+
const TEMPLATES = Object.keys(mailTemplates);
11+
12+
function buildParams(template: string, contactName: string, talkTitle: string): URLSearchParams {
13+
const params = new URLSearchParams({ template });
14+
if (contactName) params.set('contactName', contactName);
15+
if (talkTitle) params.set('talkTitle', talkTitle);
16+
return params;
17+
}
18+
19+
function toURI(map: Record<string, string>): string {
20+
return Object.entries(map)
21+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
22+
.join('&');
23+
}
24+
25+
function CopyableZone({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
26+
const [copied, setCopied] = useState(false);
27+
const handleCopy = async () => {
28+
await navigator.clipboard.writeText(value);
29+
setCopied(true);
30+
setTimeout(() => setCopied(false), 2000);
31+
};
32+
return (
33+
<div className={styles.previewField}>
34+
<span className={styles.previewLabel}>{label}</span>
35+
<div className={styles.previewZone}>
36+
<pre className={valueClassName}>{value}</pre>
37+
<button type="button" className={styles.copyButton} onClick={handleCopy} aria-label={`Copier ${label}`}>
38+
{copied ? <Check size={14} /> : <Copy size={14} />}
39+
{copied ? ' Copié' : ' Copier'}
40+
</button>
41+
</div>
42+
</div>
43+
);
44+
}
45+
46+
export default function AdminMail() {
47+
const [template, setTemplate] = useState(TEMPLATES[0]);
48+
const [contactName, setContactName] = useState('');
49+
const [talkTitle, setTalkTitle] = useState('');
50+
const [email, setEmail] = useState('');
51+
const [loading, setLoading] = useState(false);
52+
const [previewLoading, setPreviewLoading] = useState(false);
53+
const [subject, setSubject] = useState('');
54+
const [body, setBody] = useState('');
55+
const [showPreview, setShowPreview] = useState(false);
56+
57+
const handlePreview = async () => {
58+
setPreviewLoading(true);
59+
const params = buildParams(template, contactName, talkTitle);
60+
const response = await fetch(`/api/mail?${params}`);
61+
const data = await response.json();
62+
if (response.ok) {
63+
setSubject(data.subject);
64+
setBody(data.body);
65+
setShowPreview(true);
66+
}
67+
setPreviewLoading(false);
68+
};
69+
70+
const handleSubmit: ComponentProps<'form'>['onSubmit'] = async (e) => {
71+
e.preventDefault();
72+
setLoading(true);
73+
const params = buildParams(template, contactName, talkTitle);
74+
const response = await fetch(`/api/mail?${params}`);
75+
const data = await response.json();
76+
if (response.ok) {
77+
const mailToParams = {
78+
cc: 'contact@lyonjs.org',
79+
subject: data.subject,
80+
body: data.body,
81+
};
82+
window.location.href = `mailto:${encodeURIComponent(email)}?${toURI(mailToParams)}`;
83+
}
84+
setLoading(false);
85+
};
86+
87+
return (
88+
<main className={styles.container}>
89+
<h1>Générateur de mail</h1>
90+
<form onSubmit={handleSubmit} className={styles.form}>
91+
<div className={styles.field}>
92+
<label htmlFor="template">Template</label>
93+
<select id="template" value={template} onChange={(e) => setTemplate(e.target.value)}>
94+
{TEMPLATES.map((t) => (
95+
<option key={t} value={t}>
96+
{t}
97+
</option>
98+
))}
99+
</select>
100+
</div>
101+
102+
<div className={styles.field}>
103+
<label htmlFor="email">Email du destinataire</label>
104+
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
105+
</div>
106+
107+
<div className={styles.field}>
108+
<label htmlFor="contactName">Nom du contact (optionnel)</label>
109+
<input id="contactName" type="text" value={contactName} onChange={(e) => setContactName(e.target.value)} />
110+
</div>
111+
112+
<div className={styles.field}>
113+
<label htmlFor="talkTitle">Titre du talk (optionnel)</label>
114+
<input id="talkTitle" type="text" value={talkTitle} onChange={(e) => setTalkTitle(e.target.value)} />
115+
</div>
116+
117+
<div className={styles.formActions}>
118+
<button type="button" className={styles.buttonSecondary} onClick={handlePreview} disabled={previewLoading}>
119+
{previewLoading ? 'Chargement...' : 'Prévisualiser le mail'}
120+
</button>
121+
<button type="submit" className={styles.buttonIcon} disabled={loading} aria-label="Ouvrir le mail">
122+
{loading ? (
123+
'…'
124+
) : (
125+
<>
126+
Ouvrir le mail <ExternalLink size={16} />
127+
</>
128+
)}
129+
</button>
130+
</div>
131+
</form>
132+
133+
{showPreview && (
134+
<section className={styles.previewSection} aria-label="Aperçu du mail">
135+
<CopyableZone label="Objet" value={subject} valueClassName={styles.previewSubjectText} />
136+
<CopyableZone label="Contenu" value={body} valueClassName={styles.previewBodyText} />
137+
</section>
138+
)}
139+
</main>
140+
);
141+
}

app/api/mail/route.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { mailTemplates } from '../../../data/mail-templates';
3+
import { schedule } from '../../../data/schedule';
4+
5+
export async function GET(request: NextRequest) {
6+
const searchParams = request.nextUrl.searchParams;
7+
const templateName = searchParams.get('template');
8+
const talkTitle = searchParams.get('talkTitle') ?? undefined;
9+
const contactName = searchParams.get('contactName') ?? undefined;
10+
11+
const template = templateName ? mailTemplates[templateName] : undefined;
12+
13+
if (!template) {
14+
return NextResponse.json({ error: `${templateName} not found` }, { status: 404 });
15+
}
16+
17+
const subject =
18+
typeof template.subject === 'string'
19+
? template.subject
20+
: template.subject({ talkTitle, contactName, dates: schedule });
21+
const body =
22+
typeof template.body === 'string' ? template.body : template.body({ talkTitle, contactName, dates: schedule });
23+
24+
return NextResponse.json({
25+
subject,
26+
body,
27+
});
28+
}

data/mail-templates.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { speakerAcceptedTalk } from './templates/speaker-accepted-talk';
2+
import { type MailTemplate } from './templates/types';
3+
import { speakerInvitationToTalk } from './templates/speaker-invitation-to-talk';
4+
import { sponsorRecontact } from './templates/sponsor-recontact';
5+
6+
export const mailTemplates: Record<string, MailTemplate> = {
7+
speakerAcceptedTalk,
8+
speakerInvitationToTalk,
9+
sponsorRecontact,
10+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { MailTemplate } from './types';
2+
import { addIfDefined, datesAfterToday } from './utils';
3+
4+
export const speakerAcceptedTalk: MailTemplate = {
5+
subject: ({ talkTitle }) => `LyonJS – 🎉 On programme ton talk${addIfDefined(talkTitle, { pre: ' [', post: ']' })}`,
6+
body: ({ contactName, dates }) => `Hello${addIfDefined(contactName, { pre: ' ' })},
7+
8+
Merci encore pour ta proposition 🙌
9+
10+
On a beaucoup aimé ton sujet et on serait très heureux de le programmer lors d’un prochain LyonJS 🎉
11+
12+
Parmi les dates suivantes, quelles sont celles où tu serais disponible ?
13+
${datesAfterToday(dates)}
14+
15+
On verra ensuite ensemble les détails logistiques et ce dont tu as besoin pour être à l’aise.
16+
17+
Hâte de t’accueillir sur scène 🚀
18+
19+
L’équipe LyonJS
20+
21+
`,
22+
};

0 commit comments

Comments
 (0)