initial booking
This commit is contained in:
253
src/lib/mailjet.ts
Normal file
253
src/lib/mailjet.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import Mailjet from 'node-mailjet';
|
||||
import { formatOre } from './money';
|
||||
import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
|
||||
|
||||
let client: Mailjet | null = null;
|
||||
|
||||
function getClient(): Mailjet | null {
|
||||
if (!process.env.MAILJET_API_KEY || !process.env.MAILJET_API_SECRET) {
|
||||
return null;
|
||||
}
|
||||
if (!client) {
|
||||
client = new Mailjet({
|
||||
apiKey: process.env.MAILJET_API_KEY,
|
||||
apiSecret: process.env.MAILJET_API_SECRET,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
type BookingWithRels = Booking & {
|
||||
items: BookingItem[];
|
||||
pickupSlot: PickupSlot | null;
|
||||
};
|
||||
|
||||
type EmailStrings = {
|
||||
subject: string;
|
||||
greeting: string;
|
||||
intro: string;
|
||||
bookingNumber: string;
|
||||
orderSummary: string;
|
||||
pickup: string;
|
||||
invoiceInfo: string;
|
||||
questions: string;
|
||||
footer: string;
|
||||
subtotal: string;
|
||||
ofWhichVat: string;
|
||||
total: string;
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
|
||||
if (locale === 'sv') {
|
||||
return {
|
||||
subject: `Bokningsbekräftelse {number} — ${event}`,
|
||||
greeting: 'Hej {name},',
|
||||
intro: `Tack för din beställning av gasoltuber till ${event}. Här är din bokningsbekräftelse.`,
|
||||
bookingNumber: 'Bokningsnummer',
|
||||
orderSummary: 'Beställning',
|
||||
pickup: 'Upphämtning',
|
||||
invoiceInfo: 'Faktura skickas till organisationen efter eventet.',
|
||||
questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.',
|
||||
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
|
||||
subtotal: 'Delsumma',
|
||||
ofWhichVat: 'varav moms',
|
||||
total: 'Totalt',
|
||||
eventName: event,
|
||||
};
|
||||
}
|
||||
return {
|
||||
subject: `Booking confirmation {number} — ${event}`,
|
||||
greeting: 'Hi {name},',
|
||||
intro: `Thank you for ordering LPG cylinders for ${event}. Here is your booking confirmation.`,
|
||||
bookingNumber: 'Booking number',
|
||||
orderSummary: 'Order',
|
||||
pickup: 'Pickup',
|
||||
invoiceInfo: 'An invoice will be sent to your organization after the event.',
|
||||
questions: 'Questions? Reply to this email and we will help you.',
|
||||
footer: 'This is an automated confirmation from Gasol247.',
|
||||
subtotal: 'Subtotal',
|
||||
ofWhichVat: 'of which VAT',
|
||||
total: 'Total',
|
||||
eventName: event,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderBookingEmail(
|
||||
booking: BookingWithRels,
|
||||
locale: 'sv' | 'en' = 'sv',
|
||||
): { subject: string; html: string; text: string } {
|
||||
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
|
||||
const s = getStrings(locale, event);
|
||||
const fmt = (ore: number) => formatOre(ore, locale);
|
||||
const itemName = (it: BookingItem) =>
|
||||
locale === 'sv' ? it.nameSv : it.nameEn;
|
||||
const slotLabel = booking.pickupSlot
|
||||
? locale === 'sv'
|
||||
? booking.pickupSlot.labelSv
|
||||
: booking.pickupSlot.labelEn
|
||||
: null;
|
||||
const slotTime = booking.pickupSlot
|
||||
? booking.pickupSlot.startsAt.toLocaleString(
|
||||
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
)
|
||||
: null;
|
||||
|
||||
const subject = s.subject.replace('{number}', booking.bookingNumber);
|
||||
const greeting = s.greeting.replace('{name}', booking.contactName);
|
||||
|
||||
const itemRowsHtml = booking.items
|
||||
.map(
|
||||
(it) => `
|
||||
<tr>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;">
|
||||
${escapeHtml(itemName(it))} <span style="color:#94a3b8;">× ${it.quantity}</span>
|
||||
</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
|
||||
${fmt(it.lineTotalOre)}
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const pickupHtml =
|
||||
slotLabel && slotTime
|
||||
? `
|
||||
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${s.pickup}</h2>
|
||||
<p style="font-size:14px;color:#0f172a;margin:0;">${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}</p>`
|
||||
: '';
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="${locale}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(subject)}</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
|
||||
<tr>
|
||||
<td style="background:#ea580c;color:#ffffff;padding:24px;">
|
||||
<div style="font-size:12px;opacity:0.9;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(s.bookingNumber)}</div>
|
||||
<div style="font-size:24px;font-weight:600;margin-top:4px;">${escapeHtml(booking.bookingNumber)}</div>
|
||||
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(s.eventName)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px;">
|
||||
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(greeting)}</p>
|
||||
<p style="font-size:14px;color:#475569;margin:0 0 16px;">${escapeHtml(s.intro)}</p>
|
||||
|
||||
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:16px 0 8px;">${s.orderSummary}</h2>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
${itemRowsHtml}
|
||||
<tr>
|
||||
<td style="padding-top:12px;font-size:14px;color:#475569;">${s.subtotal}</td>
|
||||
<td style="padding-top:12px;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">${fmt(booking.subtotalOre)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;">${s.ofWhichVat}</td>
|
||||
<td style="font-size:13px;color:#64748b;text-align:right;white-space:nowrap;">${fmt(booking.vatOre)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;border-top:1px solid #e2e8f0;">${s.total}</td>
|
||||
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">${fmt(booking.totalOre)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${pickupHtml}
|
||||
|
||||
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
|
||||
${escapeHtml(s.invoiceInfo)}
|
||||
</div>
|
||||
|
||||
<p style="font-size:13px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.questions)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
|
||||
${escapeHtml(s.footer)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = [
|
||||
greeting,
|
||||
'',
|
||||
s.intro,
|
||||
'',
|
||||
`${s.bookingNumber}: ${booking.bookingNumber}`,
|
||||
'',
|
||||
`${s.orderSummary}:`,
|
||||
...booking.items.map(
|
||||
(it) => ` ${itemName(it)} × ${it.quantity} ${fmt(it.lineTotalOre)}`,
|
||||
),
|
||||
'',
|
||||
`${s.subtotal}: ${fmt(booking.subtotalOre)}`,
|
||||
`${s.ofWhichVat}: ${fmt(booking.vatOre)}`,
|
||||
`${s.total}: ${fmt(booking.totalOre)}`,
|
||||
...(slotLabel ? ['', `${s.pickup}: ${slotLabel} – ${slotTime}`] : []),
|
||||
'',
|
||||
s.invoiceInfo,
|
||||
'',
|
||||
s.questions,
|
||||
'',
|
||||
'— ' + s.footer,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
export async function sendBookingConfirmation(
|
||||
booking: BookingWithRels,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const c = getClient();
|
||||
if (!c) {
|
||||
console.warn('[mailjet] not configured — skipping email send');
|
||||
return { ok: false, error: 'mailjet-not-configured' };
|
||||
}
|
||||
|
||||
const locale = (booking.locale as 'sv' | 'en') ?? 'sv';
|
||||
const { subject, html, text } = renderBookingEmail(booking, locale);
|
||||
|
||||
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
|
||||
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
|
||||
|
||||
try {
|
||||
await c.post('send', { version: 'v3.1' }).request({
|
||||
Messages: [
|
||||
{
|
||||
From: { Email: fromEmail, Name: fromName },
|
||||
To: [{ Email: booking.email, Name: booking.contactName }],
|
||||
Subject: subject,
|
||||
HTMLPart: html,
|
||||
TextPart: text,
|
||||
CustomID: booking.bookingNumber,
|
||||
},
|
||||
],
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[mailjet] send failed', msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user