Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions app/admin/mail/page.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
141 changes: 141 additions & 0 deletions app/admin/mail/page.tsx
Original file line number Diff line number Diff line change
@@ -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, string>): 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 (
<div className={styles.previewField}>
<span className={styles.previewLabel}>{label}</span>
<div className={styles.previewZone}>
<pre className={valueClassName}>{value}</pre>
<button type="button" className={styles.copyButton} onClick={handleCopy} aria-label={`Copier ${label}`}>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? ' Copié' : ' Copier'}
</button>
</div>
</div>
);
}

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 (
<main className={styles.container}>
<h1>Générateur de mail</h1>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label htmlFor="template">Template</label>
<select id="template" value={template} onChange={(e) => setTemplate(e.target.value)}>
{TEMPLATES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>

<div className={styles.field}>
<label htmlFor="email">Email du destinataire</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>

<div className={styles.field}>
<label htmlFor="contactName">Nom du contact (optionnel)</label>
<input id="contactName" type="text" value={contactName} onChange={(e) => setContactName(e.target.value)} />
</div>

<div className={styles.field}>
<label htmlFor="talkTitle">Titre du talk (optionnel)</label>
<input id="talkTitle" type="text" value={talkTitle} onChange={(e) => setTalkTitle(e.target.value)} />
</div>

<div className={styles.formActions}>
<button type="button" className={styles.buttonSecondary} onClick={handlePreview} disabled={previewLoading}>
{previewLoading ? 'Chargement...' : 'Prévisualiser le mail'}
</button>
<button type="submit" className={styles.buttonIcon} disabled={loading} aria-label="Ouvrir le mail">
{loading ? (
'…'
) : (
<>
Ouvrir le mail <ExternalLink size={16} />
</>
)}
</button>
</div>
</form>

{showPreview && (
<section className={styles.previewSection} aria-label="Aperçu du mail">
<CopyableZone label="Objet" value={subject} valueClassName={styles.previewSubjectText} />
<CopyableZone label="Contenu" value={body} valueClassName={styles.previewBodyText} />
</section>
)}
</main>
);
}
28 changes: 28 additions & 0 deletions app/api/mail/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
10 changes: 10 additions & 0 deletions data/mail-templates.ts
Original file line number Diff line number Diff line change
@@ -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<string, MailTemplate> = {
speakerAcceptedTalk,
speakerInvitationToTalk,
sponsorRecontact,
};
22 changes: 22 additions & 0 deletions data/templates/speaker-accepted-talk.ts
Original file line number Diff line number Diff line change
@@ -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
`,
};
Loading