feat: change email template + add list of items
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
|
||||
@@ -77,6 +77,9 @@ model OpenTabSnapshot {
|
||||
version Int
|
||||
totalInCents Int
|
||||
itemCount Int
|
||||
// The tab's lines as reported by the terminal: [{ productName, quantity, unitPriceInCents }].
|
||||
// Null for terminals running an app version that doesn't report them.
|
||||
items Json?
|
||||
reportedAt DateTime
|
||||
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
||||
@@ -101,3 +102,26 @@ describe('DevicesService.sync live update signals', () => {
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync tab lines', () => {
|
||||
const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 };
|
||||
|
||||
it('stores the lines a terminal reports', async () => {
|
||||
const { service, tx } = createService();
|
||||
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] });
|
||||
|
||||
const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0];
|
||||
expect(create.items).toEqual(items);
|
||||
expect(update.items).toEqual(items);
|
||||
});
|
||||
|
||||
it('stores no lines for app versions that do not report them', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [base] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { OpenTabDto, SyncDto } from './dto/sync.dto';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -82,6 +83,7 @@ export class DevicesService {
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
items: snapshotItems(tab),
|
||||
reportedAt: now,
|
||||
},
|
||||
update: {
|
||||
@@ -89,6 +91,7 @@ export class DevicesService {
|
||||
version: tab.version,
|
||||
totalInCents: tab.totalInCents,
|
||||
itemCount: tab.itemCount,
|
||||
items: snapshotItems(tab),
|
||||
reportedAt: now,
|
||||
},
|
||||
});
|
||||
@@ -274,4 +277,10 @@ export class DevicesService {
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
|
||||
function snapshotItems(tab: OpenTabDto) {
|
||||
if (!tab.items) return Prisma.JsonNull;
|
||||
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class OpenTabDto {
|
||||
export class OpenTabItemDto {
|
||||
@ApiProperty({ example: 'Cola' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
productName: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quantity: number;
|
||||
|
||||
@ApiProperty({ example: 250 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
unitPriceInCents: number;
|
||||
}
|
||||
|
||||
export class OpenTabDto {
|
||||
@ApiProperty({ example: 'local-tab-uuid' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -26,6 +44,14 @@ class OpenTabDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
itemCount: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'Lines on the tab; omitted by older app versions' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OpenTabItemDto)
|
||||
items?: OpenTabItemDto[];
|
||||
}
|
||||
|
||||
export class SyncDto {
|
||||
|
||||
@@ -15,19 +15,25 @@ function withTemplates(files: Record<string, string>) {
|
||||
}
|
||||
|
||||
describe('EmailTemplatesService', () => {
|
||||
// The shipped templates are meant to be edited, so these only check that every value is filled in
|
||||
it('renders the shipped payment reminder with a subject and a text version', async () => {
|
||||
const service = new EmailTemplatesService();
|
||||
|
||||
const email = await service.render('payment-reminder', {
|
||||
customerName: 'Jan',
|
||||
venueName: 'Café De Kroon',
|
||||
amount: '€12.50',
|
||||
amount: '€ 12,50',
|
||||
itemCount: 3,
|
||||
hasItems: true,
|
||||
items: [{ quantity: 3, productName: 'Cola', unitPrice: '€ 2,50', lineTotal: '€ 7,50' }],
|
||||
});
|
||||
|
||||
expect(email.subject).toBe('Payment reminder for your tab at Café De Kroon');
|
||||
expect(email.html).toContain('<strong>€12.50</strong>');
|
||||
expect(email.text).toContain('Hi Jan,');
|
||||
expect(email.subject.length).toBeGreaterThan(0);
|
||||
expect(email.html).toContain('€ 12,50');
|
||||
expect(email.html).toContain('Cola');
|
||||
expect(email.text).toContain('Cola');
|
||||
expect(email.html).toContain('Jan');
|
||||
expect(email.text).toContain('Jan');
|
||||
expect(email.html).not.toContain('{{');
|
||||
expect(email.text).not.toContain('{{');
|
||||
});
|
||||
@@ -35,7 +41,9 @@ describe('EmailTemplatesService', () => {
|
||||
it('renders the shipped test email', async () => {
|
||||
const email = await new EmailTemplatesService().render('test-email', { customerName: 'Jan', venueName: 'De Kroon' });
|
||||
|
||||
expect(email.subject).toBe('Test email from De Kroon');
|
||||
expect(email.subject.length).toBeGreaterThan(0);
|
||||
expect(email.html).toContain('Jan');
|
||||
expect(`${email.subject} ${email.html}`).not.toContain('{{');
|
||||
});
|
||||
|
||||
it('escapes values in the HTML but not in the subject or text version', async () => {
|
||||
@@ -83,4 +91,59 @@ describe('EmailTemplatesService', () => {
|
||||
await expect(service.render('payment-reminder', {})).rejects.toThrow(/not found/);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('blocks', () => {
|
||||
const render = async (html: string, variables: Record<string, any>) => {
|
||||
const { service, cleanup } = withTemplates({ 'test-email.html': `<title>T</title>${html}` });
|
||||
try {
|
||||
return (await service.render('test-email', variables)).html.replace('<title>T</title>', '');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
it('repeats a list block per item, with outer values still available', async () => {
|
||||
const html = await render('{{#items}}[{{quantity}}x {{productName}} @ {{venueName}}]{{/items}}', {
|
||||
venueName: 'Kroon',
|
||||
items: [{ quantity: 10, productName: 'Cola' }, { quantity: 2, productName: 'Chips' }],
|
||||
});
|
||||
|
||||
expect(html).toBe('[10x Cola @ Kroon][2x Chips @ Kroon]');
|
||||
});
|
||||
|
||||
it('shows a conditional block only when its value is set', async () => {
|
||||
const template = '{{#hasItems}}<p>Overzicht</p>{{#items}}{{productName}}{{/items}}{{/hasItems}}end';
|
||||
|
||||
expect(await render(template, { hasItems: true, items: [{ productName: 'Cola' }] })).toBe('<p>Overzicht</p>Colaend');
|
||||
expect(await render(template, { hasItems: false, items: [] })).toBe('end');
|
||||
});
|
||||
|
||||
it('escapes values from list items', async () => {
|
||||
expect(await render('{{#items}}{{productName}}{{/items}}', { items: [{ productName: '<script>' }] })).toBe('<script>');
|
||||
});
|
||||
|
||||
it('never treats values as template syntax', async () => {
|
||||
const html = await render('{{#items}}{{productName}}{{/items}} {{customerName}}', {
|
||||
customerName: '{{venueName}}',
|
||||
venueName: 'secret',
|
||||
items: [{ productName: '{{#items}}' }],
|
||||
});
|
||||
|
||||
expect(html).toBe('{{#items}} {{venueName}}');
|
||||
});
|
||||
|
||||
it('reports a block that is never closed', async () => {
|
||||
await expect(render('{{#items}}{{productName}}', { items: [] })).rejects.toThrow(/never closed/);
|
||||
});
|
||||
|
||||
it('reports a list printed as a plain value', async () => {
|
||||
await expect(render('{{items}}', { items: [] })).rejects.toThrow(/is a list/);
|
||||
});
|
||||
|
||||
it('reports unknown placeholders inside a list item', async () => {
|
||||
await expect(render('{{#items}}{{prodcutName}}{{/items}}', { items: [{ productName: 'Cola' }] })).rejects.toThrow(
|
||||
/unknown placeholders: prodcutName/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,12 @@ export interface RenderedEmail {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type TemplateVariables = Record<string, string | number>;
|
||||
/** A value, a condition for `{{#key}}...{{/key}}`, or a list repeated by that block. */
|
||||
export type TemplateValue = string | number | boolean | TemplateVariables[];
|
||||
export type TemplateVariables = { [key: string]: TemplateValue };
|
||||
|
||||
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
|
||||
// `{{name}}`, `{{#name}}` (block start) or `{{/name}}` (block end)
|
||||
const TOKEN = String.raw`\{\{\s*([#/]?)\s*([A-Za-z0-9_]+)\s*\}\}`;
|
||||
const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
||||
|
||||
/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */
|
||||
@@ -83,22 +86,90 @@ export class EmailTemplatesService implements OnModuleInit {
|
||||
variables: TemplateVariables,
|
||||
encode: (value: string) => string,
|
||||
): string {
|
||||
const missing = new Set<string>();
|
||||
const result = template.replace(PLACEHOLDER, (_, key: string) => {
|
||||
if (!(key in variables)) {
|
||||
missing.add(key);
|
||||
return '';
|
||||
}
|
||||
return encode(String(variables[key]));
|
||||
});
|
||||
const problems: Problems = { unknown: new Set(), other: new Set() };
|
||||
const result = this.renderBlock(template, [variables], encode, problems);
|
||||
|
||||
// Never send an email with a blank where a value should be
|
||||
if (missing.size > 0) {
|
||||
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...missing].join(', ')}`);
|
||||
if (problems.unknown.size > 0) {
|
||||
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...problems.unknown].join(', ')}`);
|
||||
}
|
||||
if (problems.other.size > 0) {
|
||||
throw new EmailTemplateError(`Email template "${name}" has errors: ${[...problems.other].join('; ')}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in a single pass so values are never parsed again: a customer called "{{venueName}}"
|
||||
* stays literal text. `scopes` lists the variables visible here, innermost (current list item) first.
|
||||
*/
|
||||
private renderBlock(
|
||||
template: string,
|
||||
scopes: TemplateVariables[],
|
||||
encode: (value: string) => string,
|
||||
problems: Problems,
|
||||
): string {
|
||||
const token = new RegExp(TOKEN, 'g');
|
||||
let output = '';
|
||||
let position = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = token.exec(template))) {
|
||||
const [whole, kind, key] = match;
|
||||
output += template.slice(position, match.index);
|
||||
position = match.index + whole.length;
|
||||
|
||||
if (kind === '/') {
|
||||
problems.other.add(`{{/${key}}} has no matching {{#${key}}}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind === '#') {
|
||||
const close = new RegExp(String.raw`\{\{\s*/\s*${key}\s*\}\}`, 'g');
|
||||
close.lastIndex = position;
|
||||
const end = close.exec(template);
|
||||
if (!end) {
|
||||
problems.other.add(`{{#${key}}} is never closed with {{/${key}}}`);
|
||||
break;
|
||||
}
|
||||
output += this.renderSection(key, template.slice(position, end.index), scopes, encode, problems);
|
||||
position = end.index + end[0].length;
|
||||
token.lastIndex = position;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = lookup(key, scopes);
|
||||
if (value === undefined) {
|
||||
problems.unknown.add(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
problems.other.add(`{{${key}}} is a list; repeat it with {{#${key}}}...{{/${key}}}`);
|
||||
} else {
|
||||
output += encode(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return output + template.slice(position);
|
||||
}
|
||||
|
||||
/** A list repeats the block once per item; any other value shows it once when set. */
|
||||
private renderSection(
|
||||
key: string,
|
||||
body: string,
|
||||
scopes: TemplateVariables[],
|
||||
encode: (value: string) => string,
|
||||
problems: Problems,
|
||||
): string {
|
||||
const value = lookup(key, scopes);
|
||||
if (value === undefined) {
|
||||
problems.unknown.add(key);
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.renderBlock(body, [item, ...scopes], encode, problems)).join('');
|
||||
}
|
||||
return value ? this.renderBlock(body, scopes, encode, problems) : '';
|
||||
}
|
||||
|
||||
private async read(file: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(path.join(this.directory, file), 'utf8');
|
||||
@@ -108,3 +179,12 @@ export class EmailTemplatesService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Problems = { unknown: Set<string>; other: Set<string> };
|
||||
|
||||
function lookup(key: string, scopes: TemplateVariables[]): TemplateValue | undefined {
|
||||
for (const scope of scopes) {
|
||||
if (key in scope) return scope[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { EmailTemplatesService } from '../email/email-templates.service';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
function createService(items: unknown) {
|
||||
const prisma = {
|
||||
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
|
||||
openTabSnapshot: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
|
||||
]),
|
||||
},
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) },
|
||||
tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() },
|
||||
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
|
||||
};
|
||||
return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
|
||||
}
|
||||
|
||||
describe('RemindersService.sendReminders', () => {
|
||||
const originalFetch = global.fetch;
|
||||
let sentBodies: any[];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RESEND_API_KEY = 'test-key';
|
||||
sentBodies = [];
|
||||
global.fetch = jest.fn(async (_url: any, init: any) => {
|
||||
sentBodies.push(JSON.parse(init.body));
|
||||
return { ok: true, text: async () => '' } as Response;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
delete process.env.RESEND_API_KEY;
|
||||
});
|
||||
|
||||
it('includes the tab summary with Belgian amounts', async () => {
|
||||
const service = createService([
|
||||
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
|
||||
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
|
||||
]);
|
||||
|
||||
const result = await service.sendReminders('v1', 'admin@example.com');
|
||||
|
||||
expect(result.sent).toBe(1);
|
||||
const [email] = sentBodies;
|
||||
expect(email.to).toBe('bram@example.com');
|
||||
expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
|
||||
expect(email.text).toMatch(/2x Chips\s+€\s4,00/);
|
||||
expect(email.html).toContain('10x Cola');
|
||||
expect(email.subject).toContain('De Kroon');
|
||||
});
|
||||
|
||||
it('leaves the summary out when the terminal did not report lines', async () => {
|
||||
const service = createService(null);
|
||||
|
||||
await service.sendReminders('v1', 'admin@example.com');
|
||||
|
||||
const [email] = sentBodies;
|
||||
expect(email.text).not.toContain('10x');
|
||||
expect(email.text).toMatch(/€\s29,00/);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,8 @@ export class RemindersService {
|
||||
email,
|
||||
amountInCents: snap.totalInCents,
|
||||
itemCount: snap.itemCount,
|
||||
// Null when the terminal's app version doesn't report lines
|
||||
items: (snap.items as TabLine[] | null) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,6 +103,13 @@ export class RemindersService {
|
||||
venueName,
|
||||
amount: formatEuro(candidate.amountInCents),
|
||||
itemCount: candidate.itemCount,
|
||||
hasItems: (candidate.items?.length ?? 0) > 0,
|
||||
items: (candidate.items ?? []).map((item) => ({
|
||||
quantity: item.quantity,
|
||||
productName: item.productName,
|
||||
unitPrice: formatEuro(item.unitPriceInCents),
|
||||
lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
|
||||
})),
|
||||
});
|
||||
|
||||
const error = await this.sendEmail(candidate.email, email);
|
||||
@@ -208,6 +217,11 @@ export class RemindersService {
|
||||
}
|
||||
}
|
||||
|
||||
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
// Belgian notation, as on the dashboard: € 25,00
|
||||
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
function formatEuro(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
return euro.format(cents / 100);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export class TabsService {
|
||||
version: snapshot.version,
|
||||
totalInCents: snapshot.totalInCents,
|
||||
itemCount: snapshot.itemCount,
|
||||
// null: the terminal's app version doesn't report lines yet
|
||||
items: snapshot.items ?? null,
|
||||
deviceId: ownership.device.id,
|
||||
deviceName: ownership.device.name,
|
||||
deviceLastSeenAt: ownership.device.lastSeenAt,
|
||||
|
||||
@@ -16,6 +16,25 @@ Write `{{name}}` to insert a value. Values are HTML-escaped in the `.html` file
|
||||
and inserted as-is in the `.txt` file. A placeholder without a value stops the email from being sent,
|
||||
so a typo never reaches a customer.
|
||||
|
||||
### Blocks
|
||||
|
||||
`{{#name}} ... {{/name}}` does one of two things, depending on the value:
|
||||
|
||||
- **A list** repeats the block once per item. Inside it you can use that item's placeholders
|
||||
(and every placeholder from outside the block).
|
||||
- **Anything else** shows the block only when the value is set (e.g. `true`), and hides it otherwise.
|
||||
|
||||
```html
|
||||
{{#hasItems}}
|
||||
<p>Overzicht van je poef:</p>
|
||||
<table>
|
||||
{{#items}}<tr><td>{{quantity}}x {{productName}}</td><td>{{lineTotal}}</td></tr>{{/items}}
|
||||
</table>
|
||||
{{/hasItems}}
|
||||
```
|
||||
|
||||
Amounts are already formatted the Belgian way (`€ 25,00`).
|
||||
|
||||
### `payment-reminder`
|
||||
|
||||
Sent by "Send reminders" to customers with an unpaid balance.
|
||||
@@ -24,8 +43,14 @@ Sent by "Send reminders" to customers with an unpaid balance.
|
||||
|---|---|
|
||||
| `{{customerName}}` | Jan Peeters |
|
||||
| `{{venueName}}` | Café De Kroon |
|
||||
| `{{amount}}` | €12.50 |
|
||||
| `{{itemCount}}` | 3 |
|
||||
| `{{amount}}` | € 29,00 |
|
||||
| `{{itemCount}}` | 12 |
|
||||
| `{{#hasItems}}…{{/hasItems}}` | Shown when the tab's lines are known. Terminals on an older app version don't report them. |
|
||||
| `{{#items}}…{{/items}}` | Repeated for each line on the tab, with: |
|
||||
| `{{quantity}}` | 10 |
|
||||
| `{{productName}}` | Cola |
|
||||
| `{{unitPrice}}` | € 2,50 |
|
||||
| `{{lineTotal}}` | € 25,00 |
|
||||
|
||||
### `test-email`
|
||||
|
||||
|
||||
@@ -2,12 +2,28 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Payment reminder for your tab at {{venueName}}</title>
|
||||
<title>Herinnering aan open poef bij {{venueName}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi {{customerName}},</p>
|
||||
<p>This is a friendly reminder that you have an open tab of <strong>{{amount}}</strong> at {{venueName}} (items: {{itemCount}}).</p>
|
||||
<p>Please settle your tab at the bar at your earliest convenience.</p>
|
||||
<p>Thank you!</p>
|
||||
<p>Beste {{customerName}},</p>
|
||||
<p>Dit is een kleine reminder om je poef van <strong>{{amount}}</strong> nog te betalen (items: {{itemCount}}).</p>
|
||||
{{#hasItems}}
|
||||
<p>Overzicht van je poef:</p>
|
||||
<table cellpadding="4" cellspacing="0" style="border-collapse: collapse;">
|
||||
{{#items}}
|
||||
<tr>
|
||||
<td>{{quantity}}x {{productName}}</td>
|
||||
<td style="text-align: right; padding-left: 24px;">{{lineTotal}}</td>
|
||||
</tr>
|
||||
{{/items}}
|
||||
<tr>
|
||||
<td style="border-top: 1px solid #ccc;"><strong>Totaal</strong></td>
|
||||
<td style="border-top: 1px solid #ccc; text-align: right; padding-left: 24px;"><strong>{{amount}}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/hasItems}}
|
||||
<p>Gelieve je poef zo spoedig mogelijk te betalen.</p>
|
||||
<p>Met vriendelijke groet,</p>
|
||||
<p>{{venueName}}</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
Hi {{customerName}},
|
||||
Beste {{customerName}},
|
||||
|
||||
This is a friendly reminder that you have an open tab of {{amount}} at {{venueName}} (items: {{itemCount}}).
|
||||
Dit is een vriendelijke herinnering om je openstaande poef van {{amount}} bij {{venueName}} (items: {{itemCount}}) te betalen.
|
||||
{{#hasItems}}
|
||||
Overzicht van je poef:
|
||||
{{#items}}{{quantity}}x {{productName}} {{lineTotal}}
|
||||
{{/items}}Totaal: {{amount}}
|
||||
{{/hasItems}}
|
||||
Gelieve je poef zo spoedig mogelijk te betalen.
|
||||
|
||||
Please settle your tab at the bar at your earliest convenience.
|
||||
|
||||
Thank you!
|
||||
Met vriendelijke groet,
|
||||
{{venueName}}
|
||||
Reference in New Issue
Block a user