@clink/emails 1.0.2

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/README.md ADDED
@@ -0,0 +1 @@
1
+ # emails
package/dist/index.js ADDED
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.listTemplates = listTemplates;
7
+ exports.getEmailTemplate = getEmailTemplate;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const templatesRoot = path_1.default.join(__dirname, 'templates');
11
+ const htmlRoot = path_1.default.join(__dirname, 'html');
12
+ function getDirectories(root) {
13
+ if (!fs_1.default.existsSync(root))
14
+ return [];
15
+ return fs_1.default
16
+ .readdirSync(root, { withFileTypes: true })
17
+ .filter(d => d.isDirectory())
18
+ .map(d => d.name);
19
+ }
20
+ function getLocalesForTemplate(audience, template) {
21
+ const dir = path_1.default.join(templatesRoot, audience, template);
22
+ if (!fs_1.default.existsSync(dir))
23
+ return [];
24
+ return fs_1.default
25
+ .readdirSync(dir, { withFileTypes: true })
26
+ .filter(f => f.isFile() && f.name.endsWith('.json'))
27
+ .map(f => f.name.replace(/\.json$/, ''));
28
+ }
29
+ function listTemplates() {
30
+ const result = [];
31
+ const audiences = getDirectories(templatesRoot);
32
+ for (const audience of audiences) {
33
+ const audienceDir = path_1.default.join(templatesRoot, audience);
34
+ const templates = getDirectories(audienceDir);
35
+ for (const template of templates) {
36
+ const locales = getLocalesForTemplate(audience, template);
37
+ result.push({ audience, template, locales });
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+ function getEmailTemplate(audience, template, locale) {
43
+ const translationsPath = path_1.default.join(templatesRoot, audience, template, `${locale}.json`);
44
+ const htmlPath = path_1.default.join(htmlRoot, audience, template, `${locale}.html`);
45
+ if (!fs_1.default.existsSync(translationsPath)) {
46
+ throw new Error(`Missing translations for audience="${audience}" template="${template}" locale="${locale}"`);
47
+ }
48
+ if (!fs_1.default.existsSync(htmlPath)) {
49
+ throw new Error(`Missing HTML for audience="${audience}" template="${template}" locale="${locale}". Did you run the build step?`);
50
+ }
51
+ const translations = JSON.parse(fs_1.default.readFileSync(translationsPath, 'utf8'));
52
+ const html = fs_1.default.readFileSync(htmlPath, 'utf8');
53
+ if (typeof translations.subject !== 'string') {
54
+ throw new Error(`Translations for audience="${audience}" template="${template}" locale="${locale}" do not contain a "subject" key`);
55
+ }
56
+ return {
57
+ subject: translations.subject,
58
+ html,
59
+ };
60
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@clink/emails",
3
+ "version": "1.0.2",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc && node dist/build-emails.js",
8
+ "prepublishOnly": "yarn build"
9
+ },
10
+ "dependencies": {
11
+ "@types/mjml": "^4.7.4",
12
+ "mjml": "^4.2.1"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^24.10.1",
16
+ "ts-node": "^10.9.2",
17
+ "typescript": "^5.9.3"
18
+ }
19
+ }
@@ -0,0 +1,114 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import mjml2html from 'mjml'
4
+
5
+ const repoRoot = path.join(__dirname, '..')
6
+ const templatesSrcRoot = path.join(repoRoot, 'src', 'templates')
7
+ const templatesRoot = path.join(__dirname, 'templates')
8
+ const htmlRoot = path.join(__dirname, 'html')
9
+
10
+ function ensureDir(dir: string) {
11
+ if (!fs.existsSync(dir)) {
12
+ fs.mkdirSync(dir, {recursive: true})
13
+ }
14
+ }
15
+
16
+ function copyDir(src: string, dest: string) {
17
+ if (!fs.existsSync(src)) return
18
+ ensureDir(dest)
19
+ const entries = fs.readdirSync(src, {withFileTypes: true})
20
+ for (const entry of entries) {
21
+ const srcPath = path.join(src, entry.name)
22
+ const destPath = path.join(dest, entry.name)
23
+ if (entry.isDirectory()) {
24
+ copyDir(srcPath, destPath)
25
+ } else {
26
+ fs.copyFileSync(srcPath, destPath)
27
+ }
28
+ }
29
+ }
30
+
31
+ function read(file: string) {
32
+ return fs.readFileSync(file, 'utf8')
33
+ }
34
+
35
+ function write(file: string, content: string) {
36
+ fs.mkdirSync(path.dirname(file), {recursive: true})
37
+ fs.writeFileSync(file, content)
38
+ }
39
+
40
+ function replaceLocaleTokens(mjml: string, dict: Record<string, string>) {
41
+ let output = mjml
42
+ for (const [key, value] of Object.entries(dict)) {
43
+ output = output.split(`[[${key}]]`).join(value)
44
+ }
45
+ return output
46
+ }
47
+
48
+ function getDirectories(root: string) {
49
+ if (!fs.existsSync(root)) return []
50
+ return fs
51
+ .readdirSync(root, {withFileTypes: true})
52
+ .filter(d => d.isDirectory())
53
+ .map(d => d.name)
54
+ }
55
+
56
+ function getLocaleFiles(templateDir: string) {
57
+ if (!fs.existsSync(templateDir)) return []
58
+ return fs
59
+ .readdirSync(templateDir, {withFileTypes: true})
60
+ .filter(f => f.isFile() && f.name.endsWith('.json'))
61
+ .map(f => f.name)
62
+ }
63
+
64
+ function buildTemplate(
65
+ audience: string,
66
+ templateName: string,
67
+ localeFile: string,
68
+ ) {
69
+ const locale = localeFile.replace(/\.json$/, '')
70
+ const templateDir = path.join(templatesRoot, audience, templateName)
71
+ const contentPath = path.join(templateDir, 'template.mjml')
72
+ const translationsPath = path.join(templateDir, localeFile)
73
+ if (!fs.existsSync(contentPath)) return
74
+
75
+ const baseTemplatePath = path.join(__dirname, 'layouts', 'base.mjml')
76
+ const baseTemplate = read(baseTemplatePath)
77
+ const content = read(contentPath)
78
+ const translations = JSON.parse(read(translationsPath)) as Record<
79
+ string,
80
+ string
81
+ >
82
+
83
+ const localizedContent = replaceLocaleTokens(content, translations)
84
+
85
+ let mjmlSource = baseTemplate
86
+ mjmlSource = mjmlSource.replace('%CONTENT%', localizedContent)
87
+ mjmlSource = replaceLocaleTokens(mjmlSource, translations)
88
+
89
+ const {html} = mjml2html(mjmlSource, {minify: true})
90
+ const outPath = path.join(htmlRoot, audience, templateName, `${locale}.html`)
91
+ write(outPath, html)
92
+ }
93
+
94
+ function buildAll() {
95
+ copyDir(templatesSrcRoot, templatesRoot)
96
+ const layoutsSrcRoot = path.join(repoRoot, 'src', 'layouts')
97
+ const layoutsRoot = path.join(__dirname, 'layouts')
98
+ copyDir(layoutsSrcRoot, layoutsRoot)
99
+
100
+ const audiences = getDirectories(templatesRoot)
101
+ for (const audience of audiences) {
102
+ const audienceDir = path.join(templatesRoot, audience)
103
+ const templateNames = getDirectories(audienceDir)
104
+ for (const templateName of templateNames) {
105
+ const templateDir = path.join(audienceDir, templateName)
106
+ const localeFiles = getLocaleFiles(templateDir)
107
+ for (const localeFile of localeFiles) {
108
+ buildTemplate(audience, templateName, localeFile)
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ buildAll()
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ export type Locale = 'da' | 'en'
5
+ export type Audience = 'buyer' | 'supplier'
6
+
7
+ export type EmailTemplate = {
8
+ subject: string
9
+ html: string
10
+ }
11
+
12
+ export type TemplateDefinition = {
13
+ audience: string
14
+ template: string
15
+ locales: string[]
16
+ }
17
+
18
+ const templatesRoot = path.join(__dirname, 'templates')
19
+ const htmlRoot = path.join(__dirname, 'html')
20
+
21
+ function getDirectories(root: string) {
22
+ if (!fs.existsSync(root)) return []
23
+ return fs
24
+ .readdirSync(root, {withFileTypes: true})
25
+ .filter(d => d.isDirectory())
26
+ .map(d => d.name)
27
+ }
28
+
29
+ function getLocalesForTemplate(audience: string, template: string) {
30
+ const dir = path.join(templatesRoot, audience, template)
31
+ if (!fs.existsSync(dir)) return []
32
+ return fs
33
+ .readdirSync(dir, {withFileTypes: true})
34
+ .filter(f => f.isFile() && f.name.endsWith('.json'))
35
+ .map(f => f.name.replace(/\.json$/, ''))
36
+ }
37
+
38
+ export function listTemplates(): TemplateDefinition[] {
39
+ const result: TemplateDefinition[] = []
40
+ const audiences = getDirectories(templatesRoot)
41
+ for (const audience of audiences) {
42
+ const audienceDir = path.join(templatesRoot, audience)
43
+ const templates = getDirectories(audienceDir)
44
+ for (const template of templates) {
45
+ const locales = getLocalesForTemplate(audience, template)
46
+ result.push({audience, template, locales})
47
+ }
48
+ }
49
+ return result
50
+ }
51
+
52
+ export function getEmailTemplate(
53
+ audience: Audience,
54
+ template: string,
55
+ locale: Locale,
56
+ ): EmailTemplate {
57
+ const translationsPath = path.join(
58
+ templatesRoot,
59
+ audience,
60
+ template,
61
+ `${locale}.json`,
62
+ )
63
+ const htmlPath = path.join(htmlRoot, audience, template, `${locale}.html`)
64
+
65
+ if (!fs.existsSync(translationsPath)) {
66
+ throw new Error(
67
+ `Missing translations for audience="${audience}" template="${template}" locale="${locale}"`,
68
+ )
69
+ }
70
+
71
+ if (!fs.existsSync(htmlPath)) {
72
+ throw new Error(
73
+ `Missing HTML for audience="${audience}" template="${template}" locale="${locale}". Did you run the build step?`,
74
+ )
75
+ }
76
+
77
+ const translations = JSON.parse(
78
+ fs.readFileSync(translationsPath, 'utf8'),
79
+ ) as Record<string, string>
80
+ const html = fs.readFileSync(htmlPath, 'utf8')
81
+
82
+ if (typeof translations.subject !== 'string') {
83
+ throw new Error(
84
+ `Translations for audience="${audience}" template="${template}" locale="${locale}" do not contain a "subject" key`,
85
+ )
86
+ }
87
+
88
+ return {
89
+ subject: translations.subject,
90
+ html,
91
+ }
92
+ }
@@ -0,0 +1,132 @@
1
+ <mjml>
2
+ <mj-head>
3
+ <mj-font name="Arial" />
4
+ <mj-attributes>
5
+ <mj-all
6
+ font-family="Arial, sans-serif"
7
+ line-height="24px"
8
+ font-size="16px"
9
+ color="#222730"
10
+ />
11
+ <mj-wrapper background-color="#ffffff" />
12
+ <mj-divider
13
+ border-width="1px"
14
+ border-color="#dadada"
15
+ padding="16px 0px 8px 0px"
16
+ />
17
+ <mj-section padding="0px" />
18
+ <!-- <mj-text padding="0px 16px" /> -->
19
+ <mj-text padding="0px 24px" />
20
+ <!-- <mj-image padding="0px 16px" /> -->
21
+ <mj-image padding="0px 0px" />
22
+ <mj-table padding="0px 24px" />
23
+ <mj-button
24
+ border-radius="999px"
25
+ background-color="#FA3232"
26
+ color="#ffffff"
27
+ align="left"
28
+ font-size="16px"
29
+ />
30
+ <mj-accordion border="none" padding="1px" />
31
+ <mj-accordion-element
32
+ icon-wrapped-url="https://i.imgur.com/Xvw0vjq.png"
33
+ icon-unwrapped-url="https://i.imgur.com/KKHenWa.png"
34
+ icon-height="24px"
35
+ icon-width="24px"
36
+ />
37
+ <mj-accordion-title
38
+ font-family="Roboto, Open Sans, Helvetica, Arial, sans-serif"
39
+ background-color="#fff"
40
+ color="#031017"
41
+ padding="15px"
42
+ font-size="18px"
43
+ />
44
+ <mj-accordion-text
45
+ font-family="Open Sans, Helvetica, Arial, sans-serif"
46
+ background-color="#fafafa"
47
+ padding="15px"
48
+ color="#505050"
49
+ font-size="14px"
50
+ />
51
+ </mj-attributes>
52
+ <mj-style inline="inline">
53
+ .btn-link a { display: block; padding: 16px 24px; border-radius: 8px;
54
+ background-color: #FA3232; font-size: 16px; align: left; color: #ffffff; }
55
+ </mj-style>
56
+ <mj-style inline="inline">
57
+ .no-href-style a { text-decoration: none; color: inherit; }
58
+ </mj-style>
59
+ <mj-style inline="inline">
60
+ a, p, u, i, b, em, small, strong { line-height: 24px; } p { margin: 16px
61
+ 0px; } p.small { line-height: 1; } p.big { line-height: 2; } a {
62
+ text-decoration: underline; } h1 { font-size: 39px; line-height: 110%;
63
+ font-weight: 700; margin: 0px 0px 24px; } h2 { font-size: 31px;
64
+ line-height: 110%; font-weight: 700; margin: 24px 0px 0px; } h3 {
65
+ font-size: 25px; line-height: 110%; font-weight: 700; margin: 24px 0px
66
+ 0px; } h4 { font-size: 20px; line-height: 110%; font-weight: 700; margin:
67
+ 24px 0px 0px; } h5 { font-size: 16px; line-height: 110%; font-weight: 700;
68
+ margin: 24px 0px 0px; } .table-header { margin-top: "8px" } .menuComment
69
+ p{ margin-top: 4px; margin-bottom: 4px; } .menuComment p:first-of-type. {
70
+ margin-top: 0px; } .menuComment p:last-of-type { margin-bottom: 0px; }
71
+ .bordered-col { box-shadow: 0 0 0 1px #E4EBF2; border-radius: 4px; }
72
+ </mj-style>
73
+ <mj-style>
74
+ .body-bordered { border-radius: 24px; max-width: 600px; margin: 0 auto;
75
+ outline: none; /* Ensure the border is visible */ } @media (min-width:
76
+ 600px) { .body-bordered { border-radius: 24px; max-width: 600px; margin: 0
77
+ auto; margin-top: 40px; outline: 1px solid #D6D6D6 !important; } }
78
+ .discountBox div { border-width: 1px; border-style: dashed; border-color:
79
+ #FCBC42; }
80
+ </mj-style>
81
+ </mj-head>
82
+
83
+ <mj-body css-class="body-bordered" background-color: #FFFFFF>
84
+ <mj-wrapper padding="12px 0px">
85
+ <mj-section padding="24px 0px 0px">
86
+ <mj-column>
87
+ <mj-image
88
+ width="40px"
89
+ height="40px"
90
+ align="left"
91
+ src="https://ik.imagekit.io/nuento/logo/Logomark_Uj47FzOvV.png"
92
+ padding="0px 24px 8px"
93
+ alt="Nuento"
94
+ />
95
+ </mj-column>
96
+ </mj-section>
97
+
98
+ %CONTENT%
99
+
100
+ <mj-section>
101
+ <mj-column>
102
+ <mj-divider />
103
+ </mj-column>
104
+ </mj-section>
105
+
106
+ <mj-section>
107
+ <mj-column>
108
+ <mj-text padding="24px 24px 0px">
109
+ <b>Sendt fra</b>
110
+ <br />
111
+ Nuento Denmark ApS, CVR 39000725
112
+ <br />
113
+ Lundeborgvej 4, 9220 Aalborg
114
+ </mj-text>
115
+ </mj-column>
116
+ </mj-section>
117
+
118
+ <mj-section padding="32px 0px 0px">
119
+ <mj-column>
120
+ <mj-image
121
+ width="118px"
122
+ height="40px"
123
+ align="left"
124
+ src="https://ik.imagekit.io/nuento/logo/logo-40_5iYF6s3Jp.png"
125
+ padding="0px 24px 24px"
126
+ alt="Nuento"
127
+ />
128
+ </mj-column>
129
+ </mj-section>
130
+ </mj-wrapper>
131
+ </mj-body>
132
+ </mjml>
@@ -0,0 +1,10 @@
1
+ {
2
+ "intro_greeting": "Hej {{ catering.contact.name }},",
3
+ "intro_body": "Du har modtaget en ny bestilling via Nuento med ordrenr. {{ catering_order.id8digit }}.",
4
+ "button_reply": "Svar på ordre",
5
+ "payment_link_intro": "Ordren er blevet gennemført på baggrund af et bestillingslink med følgende betegnelse:",
6
+ "delivery_line": "Levering {{ catering_order.selectedDate }} til spisetid kl. {{ catering_order.selectedTime }}",
7
+ "total_label": "Samlet ordrestørrelse",
8
+ "support_heading": "Har du brug for hjælp?",
9
+ "support_body": "Så tøv ikke med at kontakte os."
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "intro_greeting": "Hi {{ catering.contact.name }},",
3
+ "intro_body": "You have received a new order via Nuento with order no. {{ catering_order.id8digit }}.",
4
+ "button_reply": "Respond to order",
5
+ "payment_link_intro": "The order was placed using an order link with the following label:",
6
+ "delivery_line": "Delivery on {{ catering_order.selectedDate }} at {{ catering_order.selectedTime }}",
7
+ "total_label": "Total order amount",
8
+ "support_heading": "Need help?",
9
+ "support_body": "Don’t hesitate to contact us."
10
+ }
@@ -0,0 +1,81 @@
1
+ <mj-section>
2
+ <mj-column>
3
+ <mj-text>
4
+ <p>[[intro_greeting]]</p>
5
+ <p>[[intro_body]]</p>
6
+ </mj-text>
7
+ </mj-column>
8
+ </mj-section>
9
+
10
+ <mj-section>
11
+ <mj-column>
12
+ <mj-button
13
+ css-class="btn-link"
14
+ align="left"
15
+ font-size="16px"
16
+ color="#ffffff"
17
+ background-color="#11141A"
18
+ href="{{{ catering_order.approvalUrl }}}"
19
+ >
20
+ [[button_reply]]
21
+ </mj-button>
22
+ </mj-column>
23
+ </mj-section>
24
+
25
+ <mj-raw>{{#if catering_order.paymentLinkName}}</mj-raw>
26
+ <mj-section>
27
+ <mj-column>
28
+ <mj-text>
29
+ <p>[[payment_link_intro]]</p>
30
+ </mj-text>
31
+ </mj-column>
32
+ </mj-section>
33
+
34
+ <mj-section
35
+ padding-left="16px"
36
+ padding-right="16px"
37
+ padding-bottom="10px"
38
+ padding-top="10px"
39
+ >
40
+ <mj-column border-radius="8px" background-color="#E6F7FF">
41
+ <mj-text align="center">
42
+ <p><i>{{{catering_order.paymentLinkName}}}</i></p>
43
+ </mj-text>
44
+ </mj-column>
45
+ </mj-section>
46
+ <mj-raw>{{/if}}</mj-raw>
47
+
48
+ <mj-section padding-top="10px" padding-bottom="10px">
49
+ <mj-column>
50
+ <mj-text>
51
+ <p>
52
+ <font size="4">[[delivery_line]]</font>
53
+ </p>
54
+ <p>
55
+ <font size="4">[[total_label]]</font>
56
+ <br /><font size="4"><b>{{ invoice.price.total }}</b></font>
57
+ </p>
58
+ </mj-text>
59
+ </mj-column>
60
+ </mj-section>
61
+
62
+ <mj-section>
63
+ <mj-column>
64
+ <mj-divider />
65
+ </mj-column>
66
+ </mj-section>
67
+
68
+ <mj-section padding-top="10px" padding-bottom="10px">
69
+ <mj-column>
70
+ <mj-text>
71
+ <p>
72
+ <font size="4"><b>[[support_heading]]</b></font>
73
+ </p>
74
+ <p>[[support_body]]</p>
75
+ <p>
76
+ {{ support.email }}
77
+ <br /><a href="tel:{{ support.phone }}">{{ support.phone }}</a>
78
+ </p>
79
+ </mj-text>
80
+ </mj-column>
81
+ </mj-section>
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "module": "commonjs",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "declaration": true,
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "resolveJsonModule": true
11
+ },
12
+ "include": ["src"]
13
+ }