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}}
|
||||
@@ -142,7 +142,6 @@ export default function DashboardLayout({
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<LiveIndicator />
|
||||
<LanguageSelector />
|
||||
{user && (
|
||||
<div className="mb-3 text-sm">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
@@ -17,7 +17,7 @@ import { LoadingState } from '@/components/loading-state';
|
||||
import { ErrorState } from '@/components/error-state';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
|
||||
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
@@ -29,6 +29,7 @@ export default function TabsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [venueId, setVenueId] = useState<string | null>(null);
|
||||
const [closeDialog, setCloseDialog] = useState<{ open: boolean; tab: any }>({ open: false, tab: null });
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||
@@ -233,9 +234,22 @@ export default function TabsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tabs.map((tab: any) => (
|
||||
<TableRow key={tab.tabId}>
|
||||
<TableCell className="font-medium">{tab.customerName}</TableCell>
|
||||
{tabs.map((tab: any) => {
|
||||
const expanded = expandedTabs.has(tab.tabId);
|
||||
const toggle = () => setExpandedTabs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
|
||||
return next;
|
||||
});
|
||||
return (
|
||||
<Fragment key={tab.tabId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">
|
||||
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={`${expanded ? 'Hide' : 'Show'} items for ${tab.customerName}`}>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{tab.customerName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tab.email ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -265,7 +279,19 @@ export default function TabsPage() {
|
||||
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{expanded && (
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell colSpan={7} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<TabLines items={tab.items} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -290,6 +316,7 @@ export default function TabsPage() {
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
|
||||
<div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Label className="text-base font-semibold">Payment method</Label>
|
||||
@@ -310,4 +337,29 @@ export default function TabsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
|
||||
function TabLines({ items }: { items: TabLine[] | null | undefined }) {
|
||||
if (!items) {
|
||||
return <p className="text-sm text-muted-foreground">Items aren't reported by this terminal yet. Update the app on the tablet to see them.</p>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No items on this tab.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex justify-between gap-4">
|
||||
<span>
|
||||
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} each</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user