Email de test : récapitulatif de planning compilé
L'email de test reprend le modèle en cours d'édition, balises remplies avec le planning d'un bénévole au choix (premier avec un créneau par défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » — même fonction de rendu que l'envoi réel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
461742eb78
commit
440466ccf9
@@ -39,6 +39,7 @@ import type {
|
||||
VolunteerUpdate,
|
||||
AssignmentRead,
|
||||
AttendanceStatus,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
|
||||
const edition = EDITION_ID;
|
||||
@@ -587,7 +588,9 @@ export function useGeneratePositionSheets() {
|
||||
}
|
||||
|
||||
export function useSendTestEmail() {
|
||||
return useMutation({ mutationFn: (email: string) => api.notifications.sendTest(edition, email) });
|
||||
return useMutation({
|
||||
mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendPlanningNotification() {
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
VolunteerCreate,
|
||||
VolunteerUpdate,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
import { getAuthToken, notifyUnauthorized } from './session';
|
||||
import { ApiError, type PlanningApi } from './types';
|
||||
@@ -378,10 +379,10 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
sendTest: (editionId, email: string) =>
|
||||
sendTest: (editionId, body: TestEmailRequest) =>
|
||||
request(`${base(editionId)}/notifications/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -2054,10 +2054,22 @@ export function createMockApi(): PlanningApi {
|
||||
notifications: {
|
||||
// Pas de serveur SMTP en démo : rien n'est envoyé, comme
|
||||
// `EMAIL_ENABLED=false` côté serveur.
|
||||
sendTest: (_edition, email) => {
|
||||
const address = email.trim();
|
||||
if (!/^[^\s@]+@[^\s@]+$/.test(address)) throw new ApiError(422, 'notifications.invalid_email');
|
||||
return delay({ status: 'disabled' as const, detail: null, smtp_host: 'localhost:25', smtp_from: 'noreply@festival.local' });
|
||||
sendTest: (_edition, body) => {
|
||||
if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
|
||||
throw new ApiError(422, 'notifications.invalid_email');
|
||||
}
|
||||
const assigned = new Set(state.assignments.map((a) => a.volunteer_id));
|
||||
const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr'));
|
||||
const example =
|
||||
sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0];
|
||||
return delay({
|
||||
status: 'disabled' as const,
|
||||
detail: null,
|
||||
smtp_host: 'localhost:25',
|
||||
smtp_from: 'noreply@festival.local',
|
||||
example_volunteer_id: example?.id ?? null,
|
||||
example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '',
|
||||
});
|
||||
},
|
||||
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||
const ids = body.volunteer_ids ?? null;
|
||||
|
||||
@@ -611,6 +611,19 @@ export interface TestEmailResult {
|
||||
detail: string | null;
|
||||
smtp_host: string;
|
||||
smtp_from: string;
|
||||
/** Bénévole dont les données ont rempli le modèle (`null` : exemple
|
||||
* fictif). */
|
||||
example_volunteer_id: string | null;
|
||||
example_volunteer_name: string;
|
||||
}
|
||||
|
||||
/** Email de test : le récapitulatif de planning en cours d'édition, rempli
|
||||
* avec les données d'un bénévole, envoyé à `email`. */
|
||||
export interface TestEmailRequest {
|
||||
email: string;
|
||||
subject?: string | null;
|
||||
body?: string | null;
|
||||
volunteer_id?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -58,6 +58,7 @@ import type {
|
||||
VolunteerUpdate,
|
||||
EditionStats,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
} from './schema';
|
||||
|
||||
@@ -226,7 +227,7 @@ export interface PlanningApi {
|
||||
body: PlanningNotificationRequest,
|
||||
): Promise<PlanningNotificationResult>;
|
||||
/** Email de test, envoyé tout de suite (COORDINATION). */
|
||||
sendTest(editionId: string, email: string): Promise<TestEmailResult>;
|
||||
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
|
||||
};
|
||||
auth: {
|
||||
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
||||
|
||||
@@ -199,7 +199,12 @@
|
||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
||||
"config.mail.test.title": "Tester l'envoi d'emails",
|
||||
"config.mail.test.help": "Envoie tout de suite un email de test à l'adresse de votre choix, pour vérifier que les emails partent bien (liens de connexion, confirmations d'inscription, plannings).",
|
||||
"config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.",
|
||||
"config.mail.test.example": "Rempli avec le planning de",
|
||||
"config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)",
|
||||
"config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)",
|
||||
"config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.",
|
||||
"config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.",
|
||||
"config.mail.test.email": "Adresse de test",
|
||||
"config.mail.test.placeholder": "moi@exemple.fr",
|
||||
"config.mail.test.send": "Envoyer un email de test",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSendPlanningNotification, useSendTestEmail } from '@/api/hooks';
|
||||
import { useSendPlanningNotification, useSendTestEmail, useVolunteers } from '@/api/hooks';
|
||||
import type { TestEmailResult } from '@/api/schema';
|
||||
import { ErrorNote } from '@/components/state';
|
||||
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerName } from '@/lib/volunteer';
|
||||
|
||||
const MAIL_VARIABLES = [
|
||||
'{prenom}',
|
||||
@@ -102,7 +103,7 @@ export function ConfigMail() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<TestEmailPanel />
|
||||
<TestEmailPanel subject={subject} body={body} />
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||
@@ -128,19 +129,34 @@ const RESULT_TONE: Record<TestEmailResult['status'], string> = {
|
||||
rejected: 'bg-orange',
|
||||
};
|
||||
|
||||
/** Envoie un email de test à l'adresse saisie et affiche le résultat réel
|
||||
* du serveur SMTP (envoyé, envoi désactivé, connexion ou message refusé). */
|
||||
function TestEmailPanel() {
|
||||
/** Envoie à l'adresse saisie le récapitulatif **tel que les bénévoles le
|
||||
* recevront** (le modèle en cours d'édition, variables remplies avec les
|
||||
* données d'un bénévole) et affiche le résultat réel du serveur SMTP. */
|
||||
function TestEmailPanel({ subject, body }: { subject: string; body: string }) {
|
||||
const [email, setEmail] = useState('');
|
||||
// '' = choix automatique (premier bénévole qui a un créneau).
|
||||
const [volunteerId, setVolunteerId] = useState('');
|
||||
const [withLink, setWithLink] = useState(false);
|
||||
const volunteers = useVolunteers();
|
||||
const test = useSendTestEmail();
|
||||
const result = test.data;
|
||||
const options = [...(volunteers.data ?? [])].sort((a, b) =>
|
||||
volunteerName(a).localeCompare(volunteerName(b), 'fr'),
|
||||
);
|
||||
return (
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.mail.test.title')}</PanelHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (email.trim()) test.mutate(email.trim());
|
||||
if (!email.trim()) return;
|
||||
test.mutate({
|
||||
email: email.trim(),
|
||||
subject,
|
||||
// Même corps que le bouton « avec lien de connexion » si coché.
|
||||
body: withLink ? withLoginLink(body) : body,
|
||||
volunteer_id: volunteerId || null,
|
||||
});
|
||||
}}
|
||||
className="flex flex-col gap-3 p-[13px]"
|
||||
>
|
||||
@@ -155,6 +171,25 @@ function TestEmailPanel() {
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.test.example')}</FieldLabel>
|
||||
<select
|
||||
value={volunteerId}
|
||||
onChange={(e) => setVolunteerId(e.target.value)}
|
||||
className="w-full border-2 border-ink bg-cream p-[8px] font-display text-[12.5px] font-bold"
|
||||
>
|
||||
<option value="">{t('config.mail.test.exampleAuto')}</option>
|
||||
{options.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{volunteerName(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 font-display text-[12px] font-bold">
|
||||
<input type="checkbox" checked={withLink} onChange={(e) => setWithLink(e.target.checked)} />
|
||||
{t('config.mail.test.withLink')}
|
||||
</label>
|
||||
<Btn type="submit" disabled={!email.trim() || test.isPending}>
|
||||
{test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')}
|
||||
</Btn>
|
||||
@@ -163,6 +198,11 @@ function TestEmailPanel() {
|
||||
<div className="font-display text-[12px] font-extrabold leading-snug">
|
||||
{t(`config.mail.test.result.${result.status}`)}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[10.5px] leading-snug">
|
||||
{result.example_volunteer_id
|
||||
? t('config.mail.test.filledWith', { name: result.example_volunteer_name || t('volunteer.unnamed') })
|
||||
: t('config.mail.test.filledWithExample')}
|
||||
</div>
|
||||
{result.detail ? (
|
||||
<div className="mt-1 break-words font-mono text-[10.5px] leading-snug">
|
||||
{t('config.mail.test.serverSaid', { detail: result.detail })}
|
||||
|
||||
Reference in New Issue
Block a user