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 }) => ( + +);