initial booking

This commit is contained in:
Ola Malmgren
2026-05-22 10:50:48 +02:00
commit 4d705a1005
77 changed files with 13827 additions and 0 deletions

253
src/lib/mailjet.ts Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}