feat: replacement

This commit is contained in:
Ola Malmgren
2026-05-22 20:33:21 +02:00
parent 4d705a1005
commit f19e2d4e0d
26 changed files with 1951 additions and 20 deletions

View File

@@ -1,6 +1,11 @@
import Mailjet from 'node-mailjet';
import { formatOre } from './money';
import type { Booking, BookingItem, PickupSlot } from '@prisma/client';
import type {
Booking,
BookingItem,
PickupSlot,
ReplacementRequest,
} from '@prisma/client';
let client: Mailjet | null = null;
@@ -251,3 +256,350 @@ function escapeHtml(s: string): string {
.replace(/"/g, '"')
.replace(/'/g, ''');
}
type MagicLinkStrings = {
subject: string;
greeting: string;
intro: string;
cta: string;
expiry: string;
fallback: string;
ignore: string;
footer: string;
};
function magicLinkStrings(locale: 'sv' | 'en', event: string): MagicLinkStrings {
if (locale === 'sv') {
return {
subject: `Inloggningslänk — ${event}`,
greeting: 'Hej,',
intro: 'Klicka på knappen nedan för att se dina bokningar och beställa byte av tomma gasolflaskor.',
cta: 'Öppna min sida',
expiry: 'Länken gäller i 1 timme och kan användas en gång.',
fallback: 'Fungerar inte knappen? Klistra in denna länk i webbläsaren:',
ignore: 'Om du inte begärt detta mejl kan du ignorera det.',
footer: 'Detta är ett automatiskt mejl från Gasol247.',
};
}
return {
subject: `Sign-in link — ${event}`,
greeting: 'Hi,',
intro: 'Click the button below to view your bookings and request empty cylinder swaps.',
cta: 'Open my page',
expiry: 'This link is valid for 1 hour and can be used once.',
fallback: 'Button not working? Paste this link into your browser:',
ignore: "If you didn't request this email you can ignore it.",
footer: 'This is an automated email from Gasol247.',
};
}
export function renderMagicLinkEmail(
link: string,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = magicLinkStrings(locale, event);
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.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:#0f172a;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.8;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(event)}</div>
<div style="font-size:22px;font-weight:600;margin-top:4px;">${escapeHtml(s.subject)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(s.greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 24px;">${escapeHtml(s.intro)}</p>
<p style="margin:0 0 16px;">
<a href="${escapeHtml(link)}" style="display:inline-block;background:#ea580c;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;font-size:15px;">${escapeHtml(s.cta)}</a>
</p>
<p style="font-size:13px;color:#64748b;margin:16px 0 0;">${escapeHtml(s.expiry)}</p>
<p style="font-size:12px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.fallback)}</p>
<p style="font-size:12px;color:#0f172a;word-break:break-all;margin:4px 0 0;"><a href="${escapeHtml(link)}" style="color:#0f172a;">${escapeHtml(link)}</a></p>
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0;">${escapeHtml(s.ignore)}</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 = [
s.greeting,
'',
s.intro,
'',
link,
'',
s.expiry,
'',
s.ignore,
'',
'— ' + s.footer,
].join('\n');
return { subject: s.subject, html, text };
}
export async function sendMagicLinkEmail(
email: string,
link: string,
locale: 'sv' | 'en' = 'sv',
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
// Dev fallback: print the link so the flow is testable without Mailjet.
console.warn(
`\n[magic-link] Mailjet not configured. Login link for ${email}:\n ${link}\n`,
);
return { ok: false, error: 'mailjet-not-configured' };
}
const { subject, html, text } = renderMagicLinkEmail(link, 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: email }],
Subject: subject,
HTMLPart: html,
TextPart: text,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] magic-link send failed', msg);
return { ok: false, error: msg };
}
}
type ReplacementWithRels = ReplacementRequest & {
booking: Booking;
bookingItem: BookingItem;
pickupSlot: PickupSlot | null;
};
type ReplacementStrings = {
subject: string;
greeting: string;
intro: string;
what: string;
total: string;
inclVat: string;
invoiceInfo: string;
bookingNumber: string;
pickup: string;
noPickup: string;
status: string;
nextSteps: string;
footer: string;
};
function replacementStrings(
locale: 'sv' | 'en',
event: string,
): ReplacementStrings {
if (locale === 'sv') {
return {
subject: `Bytesärende mottaget — ${event}`,
greeting: 'Hej {name},',
intro: 'Vi har tagit emot din begäran om byte av tomma gasolflaskor.',
what: 'Begärt byte',
total: 'Belopp',
inclVat: 'inkl. moms',
invoiceInfo:
'Bytet faktureras tillsammans med er övriga bokning efter eventet.',
bookingNumber: 'Bokningsnummer',
pickup: 'Önskad upphämtningstid',
noPickup: 'Ingen tid vald — vi återkommer med förslag.',
status: 'Status',
nextSteps: 'Vi schemalägger bytet och återkommer med bekräftelse.',
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
};
}
return {
subject: `Replacement request received — ${event}`,
greeting: 'Hi {name},',
intro: 'We have received your request to swap empty gas cylinders.',
what: 'Requested swap',
total: 'Amount',
inclVat: 'incl. VAT',
invoiceInfo:
'The swap will be invoiced together with your other booking after the event.',
bookingNumber: 'Booking number',
pickup: 'Preferred pickup time',
noPickup: 'No time selected — we will get back to you.',
status: 'Status',
nextSteps: 'We will schedule the swap and get back to you.',
footer: 'This is an automated confirmation from Gasol247.',
};
}
export function renderReplacementEmail(
rr: ReplacementWithRels,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = replacementStrings(locale, event);
const fmt = (ore: number) => formatOre(ore, locale);
const greeting = s.greeting.replace('{name}', rr.booking.contactName);
const itemName = locale === 'sv' ? rr.nameSv : rr.nameEn;
const slotLabel = rr.pickupSlot
? locale === 'sv'
? rr.pickupSlot.labelSv
: rr.pickupSlot.labelEn
: null;
const slotTime = rr.pickupSlot
? rr.pickupSlot.startsAt.toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)
: null;
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.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:22px;font-weight:600;margin-top:4px;">${escapeHtml(rr.booking.bookingNumber)}</div>
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(event)}</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;">${escapeHtml(s.what)}</h2>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="padding:4px 0;font-size:14px;color:#0f172a;">
${escapeHtml(itemName)} <span style="color:#94a3b8;">× ${rr.quantity}</span>
</td>
<td style="padding:4px 0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
${escapeHtml(fmt(rr.lineTotalOre))}
</td>
</tr>
<tr>
<td style="padding-top:6px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">${escapeHtml(s.total)}</td>
<td style="padding-top:6px;font-size:14px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">
${escapeHtml(fmt(rr.lineTotalOre))}
<span style="font-size:12px;font-weight:400;color:#64748b;">${escapeHtml(s.inclVat)}</span>
</td>
</tr>
</table>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${escapeHtml(s.pickup)}</h2>
<p style="font-size:14px;margin:0;">${
slotLabel && slotTime
? `${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}`
: `<span style="color:#64748b;">${escapeHtml(s.noPickup)}</span>`
}</p>
${
rr.notes
? `<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">Notes</h2><p style="font-size:14px;margin:0;white-space:pre-wrap;color:#475569;">${escapeHtml(rr.notes)}</p>`
: ''
}
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.nextSteps)}<br>
${escapeHtml(s.invoiceInfo)}
</div>
</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}: ${rr.booking.bookingNumber}`,
`${s.what}: ${itemName} × ${rr.quantity}`,
`${s.total}: ${fmt(rr.lineTotalOre)} (${s.inclVat})`,
`${s.pickup}: ${slotLabel ? `${slotLabel} ${slotTime}` : s.noPickup}`,
...(rr.notes ? ['', `Notes: ${rr.notes}`] : []),
'',
s.nextSteps,
s.invoiceInfo,
'',
'— ' + s.footer,
].join('\n');
return { subject: s.subject, html, text };
}
export async function sendReplacementConfirmation(
rr: ReplacementWithRels,
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
console.warn('[mailjet] not configured — skipping replacement send');
return { ok: false, error: 'mailjet-not-configured' };
}
const locale = (rr.booking.locale as 'sv' | 'en') ?? 'sv';
const { subject, html, text } = renderReplacementEmail(rr, 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: rr.booking.email, Name: rr.booking.contactName }],
Subject: subject,
HTMLPart: html,
TextPart: text,
CustomID: `replacement-${rr.id}`,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] replacement send failed', msg);
return { ok: false, error: msg };
}
}