@hed-hog/core 0.0.400 → 0.0.402
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +136 -13
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +297 -27
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +26 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +327 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +226 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +164 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +75 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +167 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +81 -2
- package/hedhog/frontend/messages/pt.json +81 -2
- package/hedhog/table/file.yaml +3 -1
- package/package.json +5 -5
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +435 -36
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +26 -0
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +326 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
- package/src/pdf/document/pdf-document.renderer.ts +317 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +149 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +256 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { PdfService } from '../pdf.service';
|
|
4
|
+
import {
|
|
5
|
+
CommercialDocumentTheme,
|
|
6
|
+
CommercialProposalData,
|
|
7
|
+
commercialProposalTemplate,
|
|
8
|
+
} from './commercial-proposal.template';
|
|
9
|
+
|
|
10
|
+
const theme = (overrides: Partial<CommercialDocumentTheme> = {}): CommercialDocumentTheme => ({
|
|
11
|
+
area: 'training',
|
|
12
|
+
companyName: 'hcode',
|
|
13
|
+
logoOverride: '',
|
|
14
|
+
primaryColor: '#FF760C',
|
|
15
|
+
darkColor: '#C45400',
|
|
16
|
+
lightColor: '#FFF0E2',
|
|
17
|
+
legalFooter: '',
|
|
18
|
+
partnerLine: '',
|
|
19
|
+
fontFamily: "'Inter', sans-serif",
|
|
20
|
+
fontMono: "'JetBrains Mono', monospace",
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const data = (overrides: Partial<CommercialProposalData> = {}): CommercialProposalData => ({
|
|
25
|
+
eyebrow: 'Proposta Comercial · BRA20260865',
|
|
26
|
+
title: 'Workshop de IA',
|
|
27
|
+
titleAccent: 'ArcelorMittal',
|
|
28
|
+
meta: [
|
|
29
|
+
{ label: 'Cliente', value: 'ArcelorMittal' },
|
|
30
|
+
{ label: 'Contato', value: '' },
|
|
31
|
+
],
|
|
32
|
+
sections: [
|
|
33
|
+
{ eyebrow: 'Sumário executivo', title: 'O que propomos.', blocks: [{ type: 'paragraphs', text: 'Um dia.' }] },
|
|
34
|
+
{ eyebrow: 'Vazia', title: 'Não aparece.', blocks: [] },
|
|
35
|
+
{
|
|
36
|
+
eyebrow: 'Investimento',
|
|
37
|
+
title: 'Itens e valores.',
|
|
38
|
+
blocks: [
|
|
39
|
+
{ type: 'table', headers: ['Item', 'Total'], rows: [['Workshop', 'R$ 1,00']], align: ['left', 'right'] },
|
|
40
|
+
{ type: 'totals', rows: [{ label: 'Total', value: 'R$ 1,00', emphasis: true }] },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('commercialProposalTemplate', () => {
|
|
48
|
+
it('numera só as seções com conteúdo, na ordem', () => {
|
|
49
|
+
const { html } = commercialProposalTemplate(data(), theme());
|
|
50
|
+
|
|
51
|
+
expect(html).toContain('01 · SUMÁRIO EXECUTIVO');
|
|
52
|
+
expect(html).toContain('02 · INVESTIMENTO');
|
|
53
|
+
expect(html).not.toContain('Não aparece.');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('escapa o texto e deixa passar só o bloco html', () => {
|
|
57
|
+
const { html } = commercialProposalTemplate(
|
|
58
|
+
data({
|
|
59
|
+
title: '<script>alert(1)</script>',
|
|
60
|
+
sections: [
|
|
61
|
+
{
|
|
62
|
+
eyebrow: 'Resumo',
|
|
63
|
+
title: 'Texto.',
|
|
64
|
+
blocks: [
|
|
65
|
+
{ type: 'paragraphs', text: '<b>cru</b>' },
|
|
66
|
+
{ type: 'html', html: '<p><strong>rico</strong></p>' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
theme(),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(html).not.toContain('<script>alert(1)</script>');
|
|
75
|
+
expect(html).toContain('<script>alert(1)</script>');
|
|
76
|
+
expect(html).toContain('<b>cru</b>');
|
|
77
|
+
expect(html).toContain('<p><strong>rico</strong></p>');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('omite metadado vazio na capa', () => {
|
|
81
|
+
const { html } = commercialProposalTemplate(data(), theme());
|
|
82
|
+
|
|
83
|
+
expect(html).toContain('<th>CLIENTE</th>');
|
|
84
|
+
expect(html).not.toContain('<th>CONTATO</th>');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('usa a logo quando há e o nome da empresa como wordmark quando não há', () => {
|
|
88
|
+
expect(commercialProposalTemplate(data(), theme({ logoOverride: 'data:image/png;base64,AA' })).html).toContain(
|
|
89
|
+
'<img class="brand-logo" src="data:image/png;base64,AA"',
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const { html } = commercialProposalTemplate(data(), theme());
|
|
93
|
+
expect(html).toContain('<span class="accent">h</span>code');
|
|
94
|
+
expect(html).toContain(' | TRAINING');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('põe o rodapé legal e a paginação n / N no rodapé de cada página', () => {
|
|
98
|
+
const { pdfOptions } = commercialProposalTemplate(
|
|
99
|
+
data(),
|
|
100
|
+
theme({ legalFooter: 'Grupo Hcode · CNPJ 24.700.731/0001-08' }),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(pdfOptions.displayHeaderFooter).toBe(true);
|
|
104
|
+
expect(pdfOptions.footerTemplate).toContain('GRUPO HCODE · CNPJ 24.700.731/0001-08');
|
|
105
|
+
expect(pdfOptions.footerTemplate).toContain('<span class="pageNumber"></span> / <span class="totalPages"></span>');
|
|
106
|
+
expect(pdfOptions.headerTemplate).toContain('background:#FF760C');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('cai no rótulo do documento quando não há rodapé legal', () => {
|
|
110
|
+
const { pdfOptions } = commercialProposalTemplate(data({ documentLabel: 'hcode · Proposta' }), theme());
|
|
111
|
+
|
|
112
|
+
expect(pdfOptions.footerTemplate).toContain('HCODE · PROPOSTA');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('mostra a linha de parceiros só quando configurada', () => {
|
|
116
|
+
expect(commercialProposalTemplate(data(), theme()).html).not.toContain('partner-line">');
|
|
117
|
+
expect(
|
|
118
|
+
commercialProposalTemplate(data(), theme({ partnerLine: 'Parceira oficial Anthropic' })).html,
|
|
119
|
+
).toContain('PARCEIRA OFICIAL ANTHROPIC');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('não deixa uma setting fora do formato quebrar o CSS', () => {
|
|
123
|
+
const { html } = commercialProposalTemplate(
|
|
124
|
+
data(),
|
|
125
|
+
theme({ primaryColor: 'red;}</style><script>', fontFamily: 'Arial;}</style>' }),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(html).toContain('--primary: #FF760C;');
|
|
129
|
+
expect(html).toContain('--font: Arial/style;');
|
|
130
|
+
expect(html).not.toContain('</style><script>');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('PdfService.loadCommercialDocumentTheme', () => {
|
|
135
|
+
const makeService = (values: Record<string, unknown>, dataUri: string | null) => {
|
|
136
|
+
const settingService = { getSettingValues: jest.fn(async () => values) };
|
|
137
|
+
const brandAsset = {
|
|
138
|
+
resolveAssetUrl: jest.fn(async (url: string) => `https://api.example.com${url}`),
|
|
139
|
+
fetchAsDataUri: jest.fn(async () => dataUri),
|
|
140
|
+
};
|
|
141
|
+
return new PdfService(settingService as any, brandAsset as any, { get: () => undefined } as any);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
it('lê rodapé, parceiros e fontes das settings e embute a logo', async () => {
|
|
145
|
+
const service = makeService(
|
|
146
|
+
{
|
|
147
|
+
'pdf-company-name': 'hcode',
|
|
148
|
+
'pdf-logo': '/logo.svg',
|
|
149
|
+
'pdf-legal-footer': 'Grupo Hcode',
|
|
150
|
+
'pdf-partner-line': 'Parceira Anthropic',
|
|
151
|
+
'pdf-font-family': 'Roboto, sans-serif',
|
|
152
|
+
'pdf-font-mono': '',
|
|
153
|
+
},
|
|
154
|
+
'data:image/svg+xml;base64,AA',
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const result = await service.loadCommercialDocumentTheme('training');
|
|
158
|
+
|
|
159
|
+
expect(result).toMatchObject({
|
|
160
|
+
companyName: 'hcode',
|
|
161
|
+
logoOverride: 'data:image/svg+xml;base64,AA',
|
|
162
|
+
legalFooter: 'Grupo Hcode',
|
|
163
|
+
partnerLine: 'Parceira Anthropic',
|
|
164
|
+
fontFamily: 'Roboto, sans-serif',
|
|
165
|
+
fontMono: "'JetBrains Mono', monospace",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('mantém a URL da logo quando não consegue embutir', async () => {
|
|
170
|
+
const service = makeService({ 'pdf-logo': '/logo.svg' }, null);
|
|
171
|
+
|
|
172
|
+
const result = await service.loadCommercialDocumentTheme('training');
|
|
173
|
+
|
|
174
|
+
expect(result.logoOverride).toBe('https://api.example.com/logo.svg');
|
|
175
|
+
expect(result.legalFooter).toBe('');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { PdfThemeConfig } from '../interfaces/pdf-theme-config.interface';
|
|
2
|
+
import type { PdfGenerateOptions } from '../pdf.service';
|
|
3
|
+
import { buildFontImport, HCODE_NEUTRALS } from '../themes/hcode.theme';
|
|
4
|
+
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Data contract
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Branding for commercial documents: the area theme plus the lines that used to
|
|
11
|
+
* be hardcoded per company (legal footer, partner line) and the font stacks.
|
|
12
|
+
* Everything comes from the `pdf` setting group, so another installation
|
|
13
|
+
* changes the look without touching code.
|
|
14
|
+
*/
|
|
15
|
+
export interface CommercialDocumentTheme extends PdfThemeConfig {
|
|
16
|
+
/** Legal line repeated in the footer of every page (company, tax id, address). */
|
|
17
|
+
legalFooter: string;
|
|
18
|
+
/** Small line under the cover metadata (partners, certifications). */
|
|
19
|
+
partnerLine: string;
|
|
20
|
+
fontFamily: string;
|
|
21
|
+
fontMono: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Blocks a section is made of. Strings are escaped by the template; only
|
|
26
|
+
* `html` goes through as is, so the caller must hand it already sanitized.
|
|
27
|
+
*/
|
|
28
|
+
export type CommercialProposalBlock =
|
|
29
|
+
| { type: 'html'; html: string }
|
|
30
|
+
| { type: 'paragraphs'; text: string }
|
|
31
|
+
| { type: 'list'; items: string[] }
|
|
32
|
+
| { type: 'callout'; title?: string; text: string; tone?: 'accent' | 'neutral' }
|
|
33
|
+
| {
|
|
34
|
+
type: 'table';
|
|
35
|
+
headers: string[];
|
|
36
|
+
rows: string[][];
|
|
37
|
+
/** Per column; defaults to left. */
|
|
38
|
+
align?: Array<'left' | 'right'>;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
type: 'totals';
|
|
42
|
+
rows: Array<{ label: string; value: string; emphasis?: boolean }>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface CommercialProposalSection {
|
|
46
|
+
/** Short uppercase label next to the number: `03 · INVESTIMENTO`. */
|
|
47
|
+
eyebrow: string;
|
|
48
|
+
/** Written as a short sentence, as the proposal standard asks. */
|
|
49
|
+
title: string;
|
|
50
|
+
blocks: CommercialProposalBlock[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CommercialProposalSignatory {
|
|
54
|
+
party: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
role?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CommercialProposalData {
|
|
60
|
+
locale?: string;
|
|
61
|
+
/** Mono line above the title: `PROPOSTA COMERCIAL · BRA20260865`. */
|
|
62
|
+
eyebrow: string;
|
|
63
|
+
title: string;
|
|
64
|
+
/** Second title line, printed in the accent color. */
|
|
65
|
+
titleAccent?: string;
|
|
66
|
+
subtitle?: string;
|
|
67
|
+
meta: Array<{ label: string; value: string }>;
|
|
68
|
+
sections: CommercialProposalSection[];
|
|
69
|
+
signatureTitle?: string;
|
|
70
|
+
signatories?: CommercialProposalSignatory[];
|
|
71
|
+
/** Left side of the footer when no legal footer is configured. */
|
|
72
|
+
documentLabel?: string;
|
|
73
|
+
/** Word used between the page number and the total (`3 / 9`). */
|
|
74
|
+
pageSeparator?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CommercialProposalRender {
|
|
78
|
+
html: string;
|
|
79
|
+
pdfOptions: PdfGenerateOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// Helpers
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function escapePdfHtml(value: unknown): string {
|
|
87
|
+
return String(value ?? '')
|
|
88
|
+
.replace(/&/g, '&')
|
|
89
|
+
.replace(/</g, '<')
|
|
90
|
+
.replace(/>/g, '>')
|
|
91
|
+
.replace(/"/g, '"')
|
|
92
|
+
.replace(/'/g, ''');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Only a plain hex color reaches the CSS; anything else falls back. */
|
|
96
|
+
function safeColor(value: string, fallback: string): string {
|
|
97
|
+
return /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim()) ? value.trim() : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Font stacks end up inside a `<style>`: drop what could close it. */
|
|
101
|
+
function safeFontStack(value: string, fallback: string): string {
|
|
102
|
+
const cleaned = String(value ?? '').replace(/[<>{};]/g, '').trim();
|
|
103
|
+
return cleaned || fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sectionNumber(index: number): string {
|
|
107
|
+
return String(index + 1).padStart(2, '0');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderParagraphs(text: string): string {
|
|
111
|
+
return String(text ?? '')
|
|
112
|
+
.split(/\n{2,}/)
|
|
113
|
+
.map((paragraph) => paragraph.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((paragraph) => `<p>${escapePdfHtml(paragraph).replace(/\n/g, '<br>')}</p>`)
|
|
116
|
+
.join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderBlock(block: CommercialProposalBlock): string {
|
|
120
|
+
switch (block.type) {
|
|
121
|
+
case 'html':
|
|
122
|
+
return `<div class="rich">${block.html}</div>`;
|
|
123
|
+
case 'paragraphs':
|
|
124
|
+
return `<div class="rich">${renderParagraphs(block.text)}</div>`;
|
|
125
|
+
case 'list':
|
|
126
|
+
return `<ul class="dash-list">${block.items
|
|
127
|
+
.map((item) => `<li>${escapePdfHtml(item)}</li>`)
|
|
128
|
+
.join('')}</ul>`;
|
|
129
|
+
case 'callout':
|
|
130
|
+
return `<div class="callout ${block.tone === 'neutral' ? 'callout-neutral' : 'callout-accent'} no-break">
|
|
131
|
+
${block.title ? `<div class="label">${escapePdfHtml(block.title)}</div>` : ''}
|
|
132
|
+
${renderParagraphs(block.text)}
|
|
133
|
+
</div>`;
|
|
134
|
+
case 'table': {
|
|
135
|
+
const align = (index: number) => (block.align?.[index] === 'right' ? ' class="num"' : '');
|
|
136
|
+
return `<table class="grid-table">
|
|
137
|
+
<thead><tr>${block.headers
|
|
138
|
+
.map((header, index) => `<th${align(index)}>${escapePdfHtml(header)}</th>`)
|
|
139
|
+
.join('')}</tr></thead>
|
|
140
|
+
<tbody>${block.rows
|
|
141
|
+
.map(
|
|
142
|
+
(row) =>
|
|
143
|
+
`<tr>${row.map((cell, index) => `<td${align(index)}>${escapePdfHtml(cell)}</td>`).join('')}</tr>`,
|
|
144
|
+
)
|
|
145
|
+
.join('')}</tbody>
|
|
146
|
+
</table>`;
|
|
147
|
+
}
|
|
148
|
+
case 'totals':
|
|
149
|
+
return `<div class="totals no-break">${block.rows
|
|
150
|
+
.map(
|
|
151
|
+
(row) =>
|
|
152
|
+
`<div class="totals-row${row.emphasis ? ' totals-strong' : ''}"><span>${escapePdfHtml(
|
|
153
|
+
row.label,
|
|
154
|
+
)}</span><span>${escapePdfHtml(row.value)}</span></div>`,
|
|
155
|
+
)
|
|
156
|
+
.join('')}</div>`;
|
|
157
|
+
default:
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Uploaded logo when there is one, otherwise the company name as a wordmark. */
|
|
163
|
+
function renderBrand(theme: CommercialDocumentTheme): string {
|
|
164
|
+
if (theme.logoOverride) {
|
|
165
|
+
return `<img class="brand-logo" src="${escapePdfHtml(theme.logoOverride)}" alt="${escapePdfHtml(
|
|
166
|
+
theme.companyName,
|
|
167
|
+
)}">`;
|
|
168
|
+
}
|
|
169
|
+
const name = String(theme.companyName || '').trim();
|
|
170
|
+
return `<div class="wordmark"><span class="accent">${escapePdfHtml(
|
|
171
|
+
name.slice(0, 1),
|
|
172
|
+
)}</span>${escapePdfHtml(name.slice(1))}<span class="wordmark-area"> | ${escapePdfHtml(
|
|
173
|
+
theme.area.toUpperCase(),
|
|
174
|
+
)}</span></div>`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
// Template
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Commercial proposal — cover with the metadata table, numbered sections
|
|
183
|
+
* (`01 · ESCOPO`), borderless tables, rounded cards and a footer with the
|
|
184
|
+
* legal line and `n / N` on every page.
|
|
185
|
+
*
|
|
186
|
+
* Pure function: the caller resolves the theme (see
|
|
187
|
+
* `PdfService.loadCommercialDocumentTheme`) and the data, so the same input
|
|
188
|
+
* always renders the same document.
|
|
189
|
+
*/
|
|
190
|
+
export function commercialProposalTemplate(
|
|
191
|
+
data: CommercialProposalData,
|
|
192
|
+
theme: CommercialDocumentTheme,
|
|
193
|
+
): CommercialProposalRender {
|
|
194
|
+
const primary = safeColor(theme.primaryColor, '#FF760C');
|
|
195
|
+
const ink = HCODE_NEUTRALS.ink1;
|
|
196
|
+
const muted = HCODE_NEUTRALS.ink2;
|
|
197
|
+
const line = HCODE_NEUTRALS.line;
|
|
198
|
+
const surface = HCODE_NEUTRALS.surface2;
|
|
199
|
+
const fontFamily = safeFontStack(theme.fontFamily, "'Inter', sans-serif");
|
|
200
|
+
const fontMono = safeFontStack(theme.fontMono, "'JetBrains Mono', monospace");
|
|
201
|
+
|
|
202
|
+
const sections = data.sections
|
|
203
|
+
.filter((section) => section.blocks.length > 0)
|
|
204
|
+
.map(
|
|
205
|
+
(section, index) => `<section class="section">
|
|
206
|
+
<div class="section-eyebrow">${sectionNumber(index)} · ${escapePdfHtml(
|
|
207
|
+
section.eyebrow.toUpperCase(),
|
|
208
|
+
)}</div>
|
|
209
|
+
<h2>${escapePdfHtml(section.title)}</h2>
|
|
210
|
+
${section.blocks.map(renderBlock).join('\n')}
|
|
211
|
+
</section>`,
|
|
212
|
+
)
|
|
213
|
+
.join('\n');
|
|
214
|
+
|
|
215
|
+
const signatories = data.signatories?.length
|
|
216
|
+
? `<section class="section signatures no-break">
|
|
217
|
+
${data.signatureTitle ? `<h2>${escapePdfHtml(data.signatureTitle)}</h2>` : ''}
|
|
218
|
+
<div class="signature-grid">${data.signatories
|
|
219
|
+
.map(
|
|
220
|
+
(signatory) => `<div class="signature">
|
|
221
|
+
<div class="signature-line"></div>
|
|
222
|
+
<div class="label">${escapePdfHtml(signatory.party)}</div>
|
|
223
|
+
${signatory.name ? `<div class="signature-name">${escapePdfHtml(signatory.name)}</div>` : ''}
|
|
224
|
+
${signatory.role ? `<div class="signature-role">${escapePdfHtml(signatory.role)}</div>` : ''}
|
|
225
|
+
</div>`,
|
|
226
|
+
)
|
|
227
|
+
.join('')}</div>
|
|
228
|
+
</section>`
|
|
229
|
+
: '';
|
|
230
|
+
|
|
231
|
+
const cover = `<div class="cover">
|
|
232
|
+
<div class="cover-brand">${renderBrand(theme)}</div>
|
|
233
|
+
<div class="cover-eyebrow">${escapePdfHtml(data.eyebrow.toUpperCase())}</div>
|
|
234
|
+
<h1 class="cover-title">${escapePdfHtml(data.title)}${
|
|
235
|
+
data.titleAccent ? `<br><span class="accent">${escapePdfHtml(data.titleAccent)}</span>` : ''
|
|
236
|
+
}</h1>
|
|
237
|
+
${data.subtitle ? `<div class="cover-subtitle">${escapePdfHtml(data.subtitle)}</div>` : ''}
|
|
238
|
+
<table class="meta-table">
|
|
239
|
+
<tbody>${data.meta
|
|
240
|
+
.filter((item) => String(item.value ?? '').trim())
|
|
241
|
+
.map(
|
|
242
|
+
(item) =>
|
|
243
|
+
`<tr><th>${escapePdfHtml(item.label.toUpperCase())}</th><td>${escapePdfHtml(item.value)}</td></tr>`,
|
|
244
|
+
)
|
|
245
|
+
.join('')}</tbody>
|
|
246
|
+
</table>
|
|
247
|
+
${theme.partnerLine ? `<div class="partner-line">${escapePdfHtml(theme.partnerLine.toUpperCase())}</div>` : ''}
|
|
248
|
+
</div>`;
|
|
249
|
+
|
|
250
|
+
const html = `<!DOCTYPE html>
|
|
251
|
+
<html lang="${escapePdfHtml(data.locale || 'pt-BR')}">
|
|
252
|
+
<head>
|
|
253
|
+
<meta charset="UTF-8">
|
|
254
|
+
${buildFontImport()}
|
|
255
|
+
<style>
|
|
256
|
+
:root {
|
|
257
|
+
--primary: ${primary};
|
|
258
|
+
--primary-soft: color-mix(in srgb, ${primary} 12%, #ffffff);
|
|
259
|
+
--ink: ${ink};
|
|
260
|
+
--muted: ${muted};
|
|
261
|
+
--line: ${line};
|
|
262
|
+
--surface: ${surface};
|
|
263
|
+
--font: ${fontFamily};
|
|
264
|
+
--mono: ${fontMono};
|
|
265
|
+
}
|
|
266
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
267
|
+
html, body {
|
|
268
|
+
font-family: var(--font);
|
|
269
|
+
font-size: 10.5pt;
|
|
270
|
+
line-height: 1.55;
|
|
271
|
+
color: var(--ink);
|
|
272
|
+
background: #ffffff;
|
|
273
|
+
-webkit-print-color-adjust: exact;
|
|
274
|
+
print-color-adjust: exact;
|
|
275
|
+
}
|
|
276
|
+
.accent { color: var(--primary); }
|
|
277
|
+
.no-break { break-inside: avoid; page-break-inside: avoid; }
|
|
278
|
+
.label {
|
|
279
|
+
font-family: var(--mono);
|
|
280
|
+
font-size: 7.5pt;
|
|
281
|
+
letter-spacing: 0.14em;
|
|
282
|
+
text-transform: uppercase;
|
|
283
|
+
color: var(--muted);
|
|
284
|
+
margin-bottom: 6px;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Cover */
|
|
288
|
+
.cover { min-height: 245mm; display: flex; flex-direction: column; break-after: page; }
|
|
289
|
+
.cover-brand { margin-bottom: 48mm; }
|
|
290
|
+
.brand-logo { max-height: 40px; max-width: 180px; object-fit: contain; }
|
|
291
|
+
.wordmark { font-size: 26pt; font-weight: 300; letter-spacing: -0.01em; line-height: 1; }
|
|
292
|
+
.wordmark-area { font-family: var(--mono); font-size: 9pt; letter-spacing: 0.2em; color: #949494; }
|
|
293
|
+
.cover-eyebrow {
|
|
294
|
+
font-family: var(--mono);
|
|
295
|
+
font-size: 8pt;
|
|
296
|
+
letter-spacing: 0.16em;
|
|
297
|
+
color: var(--muted);
|
|
298
|
+
margin-bottom: 14px;
|
|
299
|
+
}
|
|
300
|
+
.cover-title { font-size: 30pt; font-weight: 700; line-height: 1.12; letter-spacing: -0.02em; margin-bottom: 14px; }
|
|
301
|
+
.cover-subtitle { font-size: 11.5pt; color: var(--muted); margin-bottom: 28px; }
|
|
302
|
+
.meta-table { width: 100%; border-collapse: collapse; margin-top: auto; }
|
|
303
|
+
.meta-table th, .meta-table td { padding: 7px 0; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
304
|
+
.meta-table th {
|
|
305
|
+
width: 34%;
|
|
306
|
+
font-family: var(--mono);
|
|
307
|
+
font-size: 7.5pt;
|
|
308
|
+
font-weight: 400;
|
|
309
|
+
letter-spacing: 0.14em;
|
|
310
|
+
color: var(--muted);
|
|
311
|
+
}
|
|
312
|
+
.meta-table td { font-size: 10pt; }
|
|
313
|
+
.partner-line { margin-top: 18px; font-family: var(--mono); font-size: 7.5pt; letter-spacing: 0.16em; color: #949494; }
|
|
314
|
+
|
|
315
|
+
/* Sections */
|
|
316
|
+
.section { margin-bottom: 26px; }
|
|
317
|
+
.section-eyebrow { font-family: var(--mono); font-size: 8pt; letter-spacing: 0.16em; color: var(--primary); margin-bottom: 6px; }
|
|
318
|
+
h2 { font-size: 16pt; font-weight: 700; line-height: 1.25; letter-spacing: -0.01em; margin-bottom: 12px; break-after: avoid; }
|
|
319
|
+
.rich p { margin-bottom: 8px; }
|
|
320
|
+
.rich ul, .rich ol { margin: 0 0 8px 18px; }
|
|
321
|
+
.rich h3 { font-size: 11.5pt; margin: 12px 0 6px; }
|
|
322
|
+
.dash-list, .rich ul { list-style: none; margin-left: 0; }
|
|
323
|
+
.dash-list li, .rich ul li { position: relative; padding-left: 18px; margin-bottom: 5px; }
|
|
324
|
+
.dash-list li::before, .rich ul li::before {
|
|
325
|
+
content: '';
|
|
326
|
+
position: absolute;
|
|
327
|
+
left: 0;
|
|
328
|
+
top: 0.75em;
|
|
329
|
+
width: 9px;
|
|
330
|
+
height: 2px;
|
|
331
|
+
background: var(--primary);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* Cards */
|
|
335
|
+
.callout { border-radius: 10px; padding: 14px 16px; margin: 10px 0; }
|
|
336
|
+
.callout p { margin-bottom: 6px; }
|
|
337
|
+
.callout p:last-child { margin-bottom: 0; }
|
|
338
|
+
.callout-accent { background: var(--primary-soft); }
|
|
339
|
+
.callout-accent .label { color: var(--primary); }
|
|
340
|
+
.callout-neutral { background: var(--surface); }
|
|
341
|
+
|
|
342
|
+
/* Tables: horizontal rules only, no grid */
|
|
343
|
+
.grid-table { width: 100%; border-collapse: collapse; margin: 6px 0 10px; font-size: 9.5pt; }
|
|
344
|
+
.grid-table th {
|
|
345
|
+
font-family: var(--mono);
|
|
346
|
+
font-size: 7.5pt;
|
|
347
|
+
font-weight: 400;
|
|
348
|
+
letter-spacing: 0.12em;
|
|
349
|
+
text-transform: uppercase;
|
|
350
|
+
color: var(--muted);
|
|
351
|
+
text-align: left;
|
|
352
|
+
padding: 0 7px 7px;
|
|
353
|
+
border-bottom: 1px solid var(--ink);
|
|
354
|
+
}
|
|
355
|
+
.grid-table td { padding: 8px 7px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
356
|
+
.grid-table th:first-child, .grid-table td:first-child { padding-left: 0; }
|
|
357
|
+
.grid-table th:last-child, .grid-table td:last-child { padding-right: 0; }
|
|
358
|
+
.grid-table tr { break-inside: avoid; }
|
|
359
|
+
.grid-table .num { text-align: right; white-space: nowrap; }
|
|
360
|
+
|
|
361
|
+
.totals { margin: 6px 0 10px auto; width: 60%; }
|
|
362
|
+
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 10pt; }
|
|
363
|
+
.totals-strong { border-bottom: none; border-top: 2px solid var(--ink); font-weight: 700; font-size: 12pt; color: var(--primary); }
|
|
364
|
+
|
|
365
|
+
/* Signatures */
|
|
366
|
+
.signature-grid { display: flex; gap: 32px; margin-top: 36px; }
|
|
367
|
+
.signature { flex: 1; }
|
|
368
|
+
.signature-line { border-top: 1px solid var(--ink); margin-bottom: 8px; }
|
|
369
|
+
.signature-name { font-weight: 600; }
|
|
370
|
+
.signature-role { color: var(--muted); font-size: 9.5pt; }
|
|
371
|
+
</style>
|
|
372
|
+
</head>
|
|
373
|
+
<body>
|
|
374
|
+
${cover}
|
|
375
|
+
${sections}
|
|
376
|
+
${signatories}
|
|
377
|
+
</body>
|
|
378
|
+
</html>`;
|
|
379
|
+
|
|
380
|
+
const footerLeft = theme.legalFooter || data.documentLabel || theme.companyName;
|
|
381
|
+
const separator = data.pageSeparator ?? '/';
|
|
382
|
+
|
|
383
|
+
const pdfOptions: PdfGenerateOptions = {
|
|
384
|
+
format: 'A4',
|
|
385
|
+
printBackground: true,
|
|
386
|
+
displayHeaderFooter: true,
|
|
387
|
+
headerTemplate: `<div style="position:absolute;top:0;left:0;right:0;height:6px;background:${primary};-webkit-print-color-adjust:exact;print-color-adjust:exact;"></div>`,
|
|
388
|
+
footerTemplate: `<div style="width:100%;margin:0 25mm;display:flex;justify-content:space-between;gap:16px;font-family:${escapePdfHtml(
|
|
389
|
+
fontMono,
|
|
390
|
+
)};font-size:6.5pt;letter-spacing:0.12em;color:#949494;-webkit-print-color-adjust:exact;"><span>${escapePdfHtml(
|
|
391
|
+
footerLeft.toUpperCase(),
|
|
392
|
+
)}</span><span style="white-space:nowrap;"><span class="pageNumber"></span> ${escapePdfHtml(
|
|
393
|
+
separator,
|
|
394
|
+
)} <span class="totalPages"></span></span></div>`,
|
|
395
|
+
margin: { top: '22mm', right: '25mm', bottom: '22mm', left: '25mm' },
|
|
396
|
+
label: 'commercial-proposal',
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return { html, pdfOptions };
|
|
400
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { FaviconController } from './favicon.controller';
|
|
3
|
+
|
|
4
|
+
const makeRes = () => {
|
|
5
|
+
const res: any = { headers: {} as Record<string, string> };
|
|
6
|
+
res.setHeader = jest.fn((k: string, v: string) => (res.headers[k] = v));
|
|
7
|
+
res.send = jest.fn((body: Buffer) => (res.body = body));
|
|
8
|
+
return res;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const makeController = (settings: Record<string, string>) => {
|
|
12
|
+
const setting = { getSettingValues: jest.fn().mockResolvedValue(settings) };
|
|
13
|
+
const brandAsset = {
|
|
14
|
+
resolveAssetUrl: jest.fn(async (value: string) =>
|
|
15
|
+
value.startsWith('/') ? `https://app.example.com${value}` : value,
|
|
16
|
+
),
|
|
17
|
+
};
|
|
18
|
+
return new FaviconController(setting as any, brandAsset as any);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>';
|
|
22
|
+
|
|
23
|
+
describe('FaviconController', () => {
|
|
24
|
+
const originalFetch = globalThis.fetch;
|
|
25
|
+
let fetchMock: jest.Mock;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
fetchMock = jest.fn().mockResolvedValue(
|
|
29
|
+
new Response(svg, { headers: { 'content-type': 'image/svg+xml' } }),
|
|
30
|
+
);
|
|
31
|
+
globalThis.fetch = fetchMock as any;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
globalThis.fetch = originalFetch;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('serves the Branding icon with the origin content type', async () => {
|
|
39
|
+
const controller = makeController({ 'icon-url': '/logo.svg' });
|
|
40
|
+
const res = makeRes();
|
|
41
|
+
|
|
42
|
+
await controller.favicon(res);
|
|
43
|
+
|
|
44
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://app.example.com/logo.svg');
|
|
45
|
+
expect(res.headers['Content-Type']).toBe('image/svg+xml');
|
|
46
|
+
expect(res.body.toString()).toBe(svg);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('falls back to image-url and caches the download', async () => {
|
|
50
|
+
const controller = makeController({ 'icon-url': '', 'image-url': '/brand.svg' });
|
|
51
|
+
|
|
52
|
+
await controller.favicon(makeRes());
|
|
53
|
+
await controller.favicon(makeRes());
|
|
54
|
+
|
|
55
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://app.example.com/brand.svg');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('decodes a data URI without fetching', async () => {
|
|
60
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
61
|
+
const controller = makeController({
|
|
62
|
+
'icon-url': `data:image/png;base64,${png.toString('base64')}`,
|
|
63
|
+
});
|
|
64
|
+
const res = makeRes();
|
|
65
|
+
|
|
66
|
+
await controller.favicon(res);
|
|
67
|
+
|
|
68
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
69
|
+
expect(res.headers['Content-Type']).toBe('image/png');
|
|
70
|
+
expect(Buffer.compare(res.body, png)).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('answers 404 when the source is not an image', async () => {
|
|
74
|
+
fetchMock.mockResolvedValue(
|
|
75
|
+
new Response('<html/>', { headers: { 'content-type': 'text/html' } }),
|
|
76
|
+
);
|
|
77
|
+
const controller = makeController({ 'icon-url': '/logo.svg' });
|
|
78
|
+
|
|
79
|
+
await expect(controller.favicon(makeRes())).rejects.toBeInstanceOf(
|
|
80
|
+
NotFoundException,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('answers 404 when nothing is configured', async () => {
|
|
85
|
+
const controller = makeController({});
|
|
86
|
+
|
|
87
|
+
await expect(controller.favicon(makeRes())).rejects.toBeInstanceOf(
|
|
88
|
+
NotFoundException,
|
|
89
|
+
);
|
|
90
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|