Skip to content

Commit fe025c9

Browse files
committed
feat: add admin emails page to help sending emails
1 parent 609dd5b commit fe025c9

11 files changed

Lines changed: 495 additions & 1 deletion

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

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+
Parmis 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+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MailTemplate } from './types';
2+
import { addIfDefined } from './utils';
3+
4+
export const speakerInvitationToTalk: MailTemplate = {
5+
subject: `Ça te dirait de parler à LyonJS ? 🎤`,
6+
body: ({ contactName, dates }) => `Hello${addIfDefined(contactName)},
7+
8+
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 !
9+
10+
Si ça te tente, tu peux proposer un sujet via notre CFP ici :
11+
12+
👉 https://conference-hall.io/lyon-js-meetup
13+
14+
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 🙂
15+
16+
N’hésite pas si tu as des questions !
17+
18+
Au plaisir d’échanger,
19+
20+
L’équipe LyonJS
21+
22+
`,
23+
};

0 commit comments

Comments
 (0)