feat: replacement
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user