mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( notification ) add notification management
This commit is contained in:
@@ -4,7 +4,7 @@ import { NotificationsService } from './notifications.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { TestNotificationDto } from './notifications.dto';
|
||||
import { TestNotificationDto, CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { SendEmail } from './dtos/email.dto';
|
||||
@@ -21,6 +21,39 @@ export class NotificationsController {
|
||||
private smsClient: SmsClientService,
|
||||
) {}
|
||||
|
||||
// --- Template management (declared before the ':passengerId' catch-all so the
|
||||
// static 'templates' segment isn't captured as a passenger id) ---
|
||||
|
||||
@Get('templates')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'List notification templates' })
|
||||
listTemplates() {
|
||||
return this.service.listTemplates();
|
||||
}
|
||||
|
||||
@Get('templates/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Get a notification template' })
|
||||
getTemplate(@Param('id') id: string) {
|
||||
return this.service.getTemplate(id);
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Create a notification template' })
|
||||
@ApiBody({ type: CreateTemplateDto })
|
||||
createTemplate(@Body() dto: CreateTemplateDto) {
|
||||
return this.service.createTemplate(dto);
|
||||
}
|
||||
|
||||
@Patch('templates/:id')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Update a notification template (code is immutable)' })
|
||||
@ApiBody({ type: UpdateTemplateDto })
|
||||
updateTemplate(@Param('id') id: string, @Body() dto: UpdateTemplateDto) {
|
||||
return this.service.updateTemplate(id, dto);
|
||||
}
|
||||
|
||||
@Get(':passengerId')
|
||||
@ApiOperation({ summary: 'Get notifications for passenger' })
|
||||
getForPassenger(@Param('passengerId') id: string) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
||||
import { IsString, IsEnum, IsOptional, IsArray, IsBoolean, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/** A single channel, or a comma-separated list of them (e.g. "SMS,EMAIL"). */
|
||||
const CHANNEL_LIST_RE = /^(EMAIL|SMS|PUSH|IN_APP)(,(EMAIL|SMS|PUSH|IN_APP))*$/;
|
||||
const CHANNEL_MSG = 'channel must be a comma-separated list of EMAIL, SMS, PUSH, IN_APP';
|
||||
|
||||
export enum NotificationCategoryEnum {
|
||||
BOOKING = 'BOOKING',
|
||||
PAYMENT = 'PAYMENT',
|
||||
@@ -18,6 +22,55 @@ export class SendNotificationDto {
|
||||
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class CreateTemplateDto {
|
||||
@ApiProperty({ example: 'booking.created', description: 'Unique template code / event key' })
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@ApiProperty({ example: 'SMS,EMAIL', description: 'Comma-separated channels: EMAIL, SMS, PUSH, IN_APP' })
|
||||
@IsString()
|
||||
@Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
|
||||
channel: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Your train ticket is booked' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subject?: string;
|
||||
|
||||
@ApiProperty({ example: 'Dear {{passengerName}}, your booking {{bookingRef}} is booked.' })
|
||||
@IsString()
|
||||
bodyTemplate: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true, description: 'Defaults to true' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// `code` is intentionally omitted — it is the immutable event key and cannot be changed.
|
||||
export class UpdateTemplateDto {
|
||||
@ApiPropertyOptional({ example: 'SMS,EMAIL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Your train ticket is booked' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subject?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Dear {{passengerName}}, ...' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bodyTemplate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export class TestNotificationDto {
|
||||
@ApiProperty({ example: 'booking.created' })
|
||||
@IsString()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -6,6 +6,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
@@ -245,22 +246,151 @@ export class NotificationsService {
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template management (backoffice). Templates are keyed by `code`; event
|
||||
// handlers look them up by that code (e.g. 'booking.created'), so `code` is
|
||||
// immutable once created — only channel/subject/body/active are editable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
listTemplates() {
|
||||
return this.prisma.notificationTemplate.findMany({ orderBy: { code: 'asc' } });
|
||||
}
|
||||
|
||||
async getTemplate(id: string) {
|
||||
const template = await this.prisma.notificationTemplate.findUnique({ where: { id } });
|
||||
if (!template) throw new NotFoundException(`Notification template ${id} not found`);
|
||||
return template;
|
||||
}
|
||||
|
||||
async createTemplate(dto: CreateTemplateDto) {
|
||||
const existing = await this.prisma.notificationTemplate.findUnique({ where: { code: dto.code } });
|
||||
if (existing) throw new ConflictException(`Template with code "${dto.code}" already exists`);
|
||||
return this.prisma.notificationTemplate.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
channel: dto.channel,
|
||||
subject: dto.subject ?? null,
|
||||
bodyTemplate: dto.bodyTemplate,
|
||||
active: dto.active ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, dto: UpdateTemplateDto) {
|
||||
await this.getTemplate(id); // 404 if missing
|
||||
return this.prisma.notificationTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.channel !== undefined ? { channel: dto.channel } : {}),
|
||||
...(dto.subject !== undefined ? { subject: dto.subject } : {}),
|
||||
...(dto.bodyTemplate !== undefined ? { bodyTemplate: dto.bodyTemplate } : {}),
|
||||
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking created (awaiting payment) → the rich "your ticket is booked, here is the pay link"
|
||||
* message. Mirrors the operator's legacy SMS: greeting, route, train/seat line(s), travel
|
||||
* times, pay link, and the 2-hour pay-window warning (enforced by tasks.service — see
|
||||
* MAX_PAYMENT_HOURS). The body comes from the editable `booking.created` template; the shallow
|
||||
* event payload is re-fetched with schedule + seats to fill it.
|
||||
*/
|
||||
@OnEvent('booking.created')
|
||||
async onBookingCreated(payload: any) {
|
||||
const booking = payload.booking;
|
||||
await this.send(
|
||||
'booking.created',
|
||||
booking.passengerId,
|
||||
{
|
||||
bookingRef: booking.bookingRef,
|
||||
amount: this.formatAmount(booking),
|
||||
currency: booking.displayCurrency ?? 'ETB',
|
||||
category: 'BOOKING',
|
||||
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||
const bookingId = payload.booking.id;
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
// For now, always notify the travelling passenger on every channel.
|
||||
['IN_APP', 'EMAIL', 'SMS'],
|
||||
);
|
||||
});
|
||||
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
||||
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: 'booking.created' },
|
||||
});
|
||||
if (!template || !template.active) {
|
||||
this.logger.warn('booking.created template not found or inactive');
|
||||
return;
|
||||
}
|
||||
|
||||
const { subject, body } = this.interpolate(template, this.buildBookingCreatedContext(booking, ref));
|
||||
|
||||
// IN_APP — always created.
|
||||
await this.createInAppNotification(passengerId, subject, body, {
|
||||
category: 'BOOKING',
|
||||
deepLink: `edr://bookings/${ref}`,
|
||||
});
|
||||
|
||||
// SMS — the primary channel for this message. Prefer the IAM user's number, fall back to
|
||||
// the phone entered on the booking form (guest bookings have no IAM user).
|
||||
const contactPhone: string | null =
|
||||
(booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
|
||||
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
|
||||
const smsPhone = iamPhone ?? contactPhone;
|
||||
if (smsPhone) {
|
||||
await this.smsClient
|
||||
.sendSms({ to: smsPhone, message: body })
|
||||
.catch((e) => this.logger.error(`booking.created SMS failed for ${ref}: ${e?.message}`));
|
||||
} else {
|
||||
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||
}
|
||||
|
||||
// EMAIL — same text, with the same contact fallback.
|
||||
const contactEmail: string | null =
|
||||
(booking as any)?.contactEmail ?? (payload.booking as any)?.contactEmail ?? null;
|
||||
const iamEmail = passengerId ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) : null;
|
||||
const emailTo = iamEmail ?? contactEmail;
|
||||
if (emailTo) {
|
||||
await this.emailClient
|
||||
.sendEmail({ to: emailTo, subject, text: body })
|
||||
.catch((e) => this.logger.error(`booking.created email failed for ${ref}: ${e?.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
|
||||
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
|
||||
* several lines).
|
||||
*/
|
||||
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
|
||||
const s = booking?.schedule ?? {};
|
||||
const trainName = s.train?.name ?? s.train?.number ?? '';
|
||||
const fmtDate = (d: any) =>
|
||||
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
|
||||
const fmtTime = (d: any) =>
|
||||
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
|
||||
|
||||
const seats = booking?.seats ?? [];
|
||||
const trainSeatLines = seats
|
||||
.map((bs: any) => {
|
||||
const coach = bs.seat?.coach?.number ?? '-';
|
||||
const cls = bs.seat?.coach?.coachType?.name ?? '';
|
||||
const seatNo = bs.seat?.seatNumber ?? '-';
|
||||
return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
||||
|
||||
return {
|
||||
passengerName,
|
||||
bookingRef: ref,
|
||||
origin: s.originStation?.name ?? '',
|
||||
destination: s.destinationStation?.name ?? '',
|
||||
trainSeatLines,
|
||||
travelDate: fmtDate(s.departureAt),
|
||||
departureTime: fmtTime(s.departureAt),
|
||||
arrivalTime: fmtTime(s.arrivalAt),
|
||||
payLink,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,12 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import { notificationsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates');
|
||||
const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' });
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
@@ -34,10 +38,54 @@ export default function NotificationsPage() {
|
||||
mutationFn: notificationsApi.createTemplate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||
setShowModal(false);
|
||||
closeTemplateModal();
|
||||
},
|
||||
onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to create template'),
|
||||
});
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => notificationsApi.updateTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||
closeTemplateModal();
|
||||
},
|
||||
onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to update template'),
|
||||
});
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditing(null);
|
||||
setTemplateError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (template: any) => {
|
||||
setEditing(template);
|
||||
setTemplateError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeTemplateModal = () => {
|
||||
setShowModal(false);
|
||||
setEditing(null);
|
||||
setTemplateError(null);
|
||||
};
|
||||
|
||||
const handleTemplateSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setTemplateError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const channel = fd.get('channel') as string;
|
||||
const subject = (fd.get('subject') as string) || undefined;
|
||||
const bodyTemplate = fd.get('bodyTemplate') as string;
|
||||
const active = fd.get('active') === 'on';
|
||||
|
||||
if (editing) {
|
||||
await updateTemplateMutation.mutateAsync({ id: editing.id, data: { channel, subject, bodyTemplate, active } });
|
||||
} else {
|
||||
await createTemplateMutation.mutateAsync({ code: fd.get('code') as string, channel, subject, bodyTemplate, active });
|
||||
}
|
||||
};
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: notificationsApi.send,
|
||||
onSuccess: () => {
|
||||
@@ -59,23 +107,36 @@ export default function NotificationsPage() {
|
||||
const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || [];
|
||||
|
||||
const templateColumns = [
|
||||
{ key: 'name', label: 'Template Name', render: (t: any) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'channel', label: 'Channel', render: (t: any) => <Badge>{t.channel || t.type}</Badge> },
|
||||
{ key: 'code', label: 'Code / Event Key', render: (t: any) => <span className="font-mono text-sm font-medium">{t.code}</span> },
|
||||
{ key: 'channel', label: 'Channel', render: (t: any) => <Badge>{t.channel}</Badge> },
|
||||
{
|
||||
key: 'subject',
|
||||
label: 'Subject / Body',
|
||||
render: (t: any) => <span className="text-sm text-muted-foreground truncate max-w-xs block">{t.subject || t.body || t.content || '—'}</span>,
|
||||
render: (t: any) => (
|
||||
<span className="text-sm text-muted-foreground truncate max-w-xs block">
|
||||
{t.subject ? `${t.subject} — ` : ''}{(t.bodyTemplate || '').replace(/\n/g, ' ') || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Status',
|
||||
render: (t: any) => (
|
||||
<Badge variant="status" status={t.isActive !== false ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{t.isActive !== false ? 'Active' : 'Inactive'}
|
||||
<Badge variant="status" status={t.active !== false ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{t.active !== false ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', label: 'Created', render: (t: any) => <span className="text-sm text-muted-foreground">{formatDateTime(t.createdAt)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (t: any) => (
|
||||
<button onClick={() => openEditModal(t)} className="text-sm font-medium text-primary hover:underline">
|
||||
Edit
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const historyColumns = [
|
||||
@@ -106,7 +167,7 @@ export default function NotificationsPage() {
|
||||
<p className="text-muted-foreground">Manage notification templates and send messages</p>
|
||||
</div>
|
||||
{activeTab === 'templates' && (
|
||||
<ActionButton icon={Plus} onClick={() => setShowModal(true)}>New Template</ActionButton>
|
||||
<ActionButton icon={Plus} onClick={openCreateModal}>New Template</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -184,43 +245,68 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Notification Template">
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
await createTemplateMutation.mutateAsync({
|
||||
name: fd.get('name') as string,
|
||||
channel: fd.get('channel') as string,
|
||||
subject: fd.get('subject') as string,
|
||||
body: fd.get('body') as string,
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={closeTemplateModal}
|
||||
title={editing ? `Edit Template — ${editing.code}` : 'Create Notification Template'}
|
||||
>
|
||||
<form onSubmit={handleTemplateSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Template Name *</label>
|
||||
<input type="text" name="name" className="input" placeholder="e.g., Booking Confirmation" required />
|
||||
<label className="label">Code / Event Key {editing ? '' : '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
className="input font-mono"
|
||||
placeholder="e.g., booking.created"
|
||||
defaultValue={editing?.code ?? ''}
|
||||
required={!editing}
|
||||
disabled={!!editing}
|
||||
/>
|
||||
{editing && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Code is the event key and cannot be changed.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Channel</label>
|
||||
<select name="channel" className="input">
|
||||
<option value="EMAIL">Email</option>
|
||||
<option value="SMS">SMS</option>
|
||||
<option value="PUSH">Push Notification</option>
|
||||
<label className="label">Channel *</label>
|
||||
<select name="channel" className="input" defaultValue={editing?.channel ?? 'EMAIL'} required>
|
||||
{CHANNEL_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
<option value="SMS,EMAIL">SMS,EMAIL</option>
|
||||
<option value="SMS,EMAIL,IN_APP">SMS,EMAIL,IN_APP</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
One channel, or a comma-separated list (EMAIL, SMS, PUSH, IN_APP).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Subject</label>
|
||||
<input type="text" name="subject" className="input" placeholder="Enter subject" />
|
||||
<input type="text" name="subject" className="input" placeholder="Enter subject" defaultValue={editing?.subject ?? ''} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Body *</label>
|
||||
<textarea name="body" className="input" rows={4} placeholder="Enter template body" required />
|
||||
<textarea
|
||||
name="bodyTemplate"
|
||||
className="input font-mono text-sm"
|
||||
rows={8}
|
||||
placeholder="Enter template body"
|
||||
defaultValue={editing?.bodyTemplate ?? ''}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Use <code>{'{{variable}}'}</code> placeholders (e.g. <code>{'{{passengerName}}'}</code>, <code>{'{{bookingRef}}'}</code>) — they are filled in when the notification is sent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" name="active" id="template-active" defaultChecked={editing ? editing.active !== false : true} />
|
||||
<label htmlFor="template-active" className="text-sm">Active</label>
|
||||
</div>
|
||||
{templateError && <p className="text-sm text-red-600 dark:text-red-400">{templateError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setShowModal(false)}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createTemplateMutation.isPending}>Create Template</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={closeTemplateModal}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createTemplateMutation.isPending || updateTemplateMutation.isPending}>
|
||||
{editing ? 'Save Changes' : 'Create Template'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user