includio-cms 0.27.0 → 0.33.0
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/API.md +58 -14
- package/CHANGELOG.md +59 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +1 -0
- package/dist/admin/api/handler.js +4 -0
- package/dist/admin/api/integrations.d.ts +13 -0
- package/dist/admin/api/integrations.js +61 -0
- package/dist/admin/api/test-email.d.ts +9 -0
- package/dist/admin/api/test-email.js +39 -0
- package/dist/admin/auth-client.d.ts +543 -543
- package/dist/admin/client/index.d.ts +10 -0
- package/dist/admin/client/index.js +12 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +210 -0
- package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
- package/dist/admin/client/shop/restore-order-cell.svelte +29 -0
- package/dist/admin/client/shop/restore-order-cell.svelte.d.ts +8 -0
- package/dist/admin/client/shop/shop-order-detail-page.svelte +156 -1
- package/dist/admin/client/shop/shop-orders-list-page.svelte +113 -53
- package/dist/admin/components/layout/app-sidebar.svelte +2 -0
- package/dist/admin/components/layout/nav-custom.svelte +26 -0
- package/dist/admin/components/layout/nav-custom.svelte.d.ts +3 -0
- package/dist/admin/components/layout/page-header.svelte +13 -3
- package/dist/admin/components/layout/page-header.svelte.d.ts +13 -3
- package/dist/admin/remote/admin.remote.d.ts +7 -0
- package/dist/admin/remote/admin.remote.js +10 -0
- package/dist/admin/remote/entry.remote.d.ts +2 -2
- package/dist/admin/remote/index.d.ts +1 -0
- package/dist/admin/remote/index.js +1 -0
- package/dist/admin/remote/invite.d.ts +1 -1
- package/dist/admin/remote/shop.remote.d.ts +125 -40
- package/dist/admin/remote/shop.remote.js +59 -10
- package/dist/admin/types.d.ts +15 -0
- package/dist/admin/utils/csv-export.d.ts +45 -0
- package/dist/admin/utils/csv-export.js +61 -0
- package/dist/cli/scaffold/admin.js +1 -1
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/dist/core/cms.d.ts +44 -2
- package/dist/core/cms.js +64 -0
- package/dist/core/index.d.ts +2 -4
- package/dist/core/index.js +1 -4
- package/dist/core/server/index.d.ts +4 -1
- package/dist/core/server/index.js +4 -1
- package/dist/db-postgres/schema/shop/index.d.ts +1 -0
- package/dist/db-postgres/schema/shop/index.js +1 -0
- package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
- package/dist/db-postgres/schema/shop/invoice.js +27 -0
- package/dist/db-postgres/schema/shop/order.d.ts +104 -0
- package/dist/db-postgres/schema/shop/order.js +8 -0
- package/dist/shop/adapters/fakturownia/client.d.ts +33 -0
- package/dist/shop/adapters/fakturownia/client.js +87 -0
- package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
- package/dist/shop/adapters/fakturownia/index.js +47 -0
- package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
- package/dist/shop/adapters/fakturownia/payload.js +45 -0
- package/dist/shop/adapters/payu/index.js +11 -0
- package/dist/shop/client/index.d.ts +7 -0
- package/dist/shop/http/checkout-handler.js +11 -0
- package/dist/shop/index.d.ts +4 -1
- package/dist/shop/index.js +3 -0
- package/dist/shop/nip.d.ts +12 -0
- package/dist/shop/nip.js +23 -0
- package/dist/shop/server/coupons.d.ts +10 -0
- package/dist/shop/server/coupons.js +19 -0
- package/dist/shop/server/email.d.ts +7 -3
- package/dist/shop/server/email.js +86 -112
- package/dist/shop/server/emailTemplateRegistry.d.ts +47 -0
- package/dist/shop/server/emailTemplateRegistry.js +288 -0
- package/dist/shop/server/invoices.d.ts +64 -0
- package/dist/shop/server/invoices.js +237 -0
- package/dist/shop/server/orders.d.ts +64 -1
- package/dist/shop/server/orders.js +155 -15
- package/dist/shop/templates/_partials/footer.en.html +4 -0
- package/dist/shop/templates/_partials/footer.pl.html +4 -0
- package/dist/shop/templates/_partials/header.en.html +4 -0
- package/dist/shop/templates/_partials/header.pl.html +4 -0
- package/dist/shop/templates/_partials/items.en.html +14 -0
- package/dist/shop/templates/_partials/items.pl.html +14 -0
- package/dist/shop/templates/_partials/tracking.en.html +7 -0
- package/dist/shop/templates/_partials/tracking.pl.html +7 -0
- package/dist/shop/templates/awaiting-payment.en.html +6 -0
- package/dist/shop/templates/awaiting-payment.pl.html +6 -0
- package/dist/shop/templates/cancelled.en.html +6 -0
- package/dist/shop/templates/cancelled.pl.html +6 -0
- package/dist/shop/templates/low-stock.en.html +14 -0
- package/dist/shop/templates/low-stock.pl.html +14 -0
- package/dist/shop/templates/order-completed.en.html +6 -0
- package/dist/shop/templates/order-completed.pl.html +6 -0
- package/dist/shop/templates/order-received.en.html +7 -0
- package/dist/shop/templates/order-received.pl.html +7 -0
- package/dist/shop/templates/payment-received.en.html +7 -0
- package/dist/shop/templates/payment-received.pl.html +7 -0
- package/dist/shop/templates/payment-rejected.en.html +6 -0
- package/dist/shop/templates/payment-rejected.pl.html +6 -0
- package/dist/shop/templates/preparing.en.html +7 -0
- package/dist/shop/templates/preparing.pl.html +7 -0
- package/dist/shop/templates/refunded.en.html +6 -0
- package/dist/shop/templates/refunded.pl.html +6 -0
- package/dist/shop/templates/shipped.en.html +7 -0
- package/dist/shop/templates/shipped.pl.html +7 -0
- package/dist/shop/types.d.ts +130 -1
- package/dist/sveltekit/index.d.ts +0 -1
- package/dist/sveltekit/index.js +0 -1
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/types/adapters/email.d.ts +13 -0
- package/dist/types/cms.d.ts +30 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/updates/0.28.0/index.d.ts +2 -0
- package/dist/updates/0.28.0/index.js +38 -0
- package/dist/updates/0.34.0/index.d.ts +2 -0
- package/dist/updates/0.34.0/index.js +17 -0
- package/dist/updates/index.js +5 -1
- package/package.json +7 -2
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { getCMS } from '../../core/cms.js';
|
|
2
2
|
import { resolveI18n } from '../pricing.js';
|
|
3
3
|
import { getOrderById, getOrderItems } from './orders.js';
|
|
4
|
+
import { getOrderCoupon } from './coupons.js';
|
|
4
5
|
import { requireShopConfig } from './db.js';
|
|
5
6
|
import { buildOrderViewUrl } from './order-access-url.js';
|
|
6
|
-
|
|
7
|
-
pl: { label: 'Numer śledzenia', linkLabel: 'Sprawdź status przesyłki ↗' },
|
|
8
|
-
en: { label: 'Tracking number', linkLabel: 'Track your shipment ↗' }
|
|
9
|
-
};
|
|
10
|
-
function formatPrice(cents, currency) {
|
|
11
|
-
return new Intl.NumberFormat('pl-PL', {
|
|
12
|
-
style: 'currency',
|
|
13
|
-
currency,
|
|
14
|
-
minimumFractionDigits: 2
|
|
15
|
-
}).format(cents / 100);
|
|
16
|
-
}
|
|
7
|
+
import { renderEmailTemplate } from './emailTemplateRegistry.js';
|
|
17
8
|
const STATUS_SUBJECTS = {
|
|
18
9
|
new: { pl: 'Zamówienie przyjęte', en: 'Order received' },
|
|
19
10
|
awaitingPayment: { pl: 'Oczekiwanie na płatność', en: 'Awaiting payment' },
|
|
@@ -25,92 +16,41 @@ const STATUS_SUBJECTS = {
|
|
|
25
16
|
paymentRejected: { pl: 'Płatność odrzucona', en: 'Payment rejected' },
|
|
26
17
|
refunded: { pl: 'Zamówienie zwrócone', en: 'Order refunded' }
|
|
27
18
|
};
|
|
28
|
-
const
|
|
29
|
-
new:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
pl: 'Twoje zamówienie jest pakowane.',
|
|
43
|
-
en: 'Your order is being prepared.'
|
|
44
|
-
},
|
|
45
|
-
sent: {
|
|
46
|
-
pl: 'Zamówienie zostało wysłane.',
|
|
47
|
-
en: 'Your order has been shipped.'
|
|
48
|
-
},
|
|
49
|
-
done: {
|
|
50
|
-
pl: 'Zamówienie zostało zakończone. Dziękujemy za zakupy!',
|
|
51
|
-
en: 'Your order is complete. Thanks for shopping with us!'
|
|
52
|
-
},
|
|
53
|
-
cancelled: {
|
|
54
|
-
pl: 'Zamówienie zostało anulowane.',
|
|
55
|
-
en: 'Your order has been cancelled.'
|
|
56
|
-
},
|
|
57
|
-
paymentRejected: {
|
|
58
|
-
pl: 'Płatność nie została zaksięgowana. Skontaktuj się z nami w razie pytań.',
|
|
59
|
-
en: 'Payment was not received. Please contact us if you have questions.'
|
|
60
|
-
},
|
|
61
|
-
refunded: {
|
|
62
|
-
pl: 'Środki zostały zwrócone. Powinny pojawić się na koncie w ciągu kilku dni roboczych.',
|
|
63
|
-
en: 'Your refund has been issued. It should reach your account within a few business days.'
|
|
64
|
-
}
|
|
19
|
+
const STATUS_TO_TEMPLATE = {
|
|
20
|
+
new: 'order-received',
|
|
21
|
+
awaitingPayment: 'awaiting-payment',
|
|
22
|
+
paid: 'payment-received',
|
|
23
|
+
preparing: 'preparing',
|
|
24
|
+
sent: 'shipped',
|
|
25
|
+
done: 'order-completed',
|
|
26
|
+
cancelled: 'cancelled',
|
|
27
|
+
paymentRejected: 'payment-rejected',
|
|
28
|
+
refunded: 'refunded'
|
|
29
|
+
};
|
|
30
|
+
const TRACKING_LABEL = {
|
|
31
|
+
pl: { label: 'Numer śledzenia', linkLabel: 'Sprawdź status przesyłki ↗' },
|
|
32
|
+
en: { label: 'Tracking number', linkLabel: 'Track your shipment ↗' }
|
|
65
33
|
};
|
|
66
34
|
const VIEW_LINK_LABEL = {
|
|
67
35
|
pl: 'Zobacz zamówienie',
|
|
68
36
|
en: 'View order'
|
|
69
37
|
};
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<div style="max-width:560px;margin:0 auto;padding:24px;">
|
|
77
|
-
<div style="background:#fff;border-radius:12px;padding:28px;">
|
|
78
|
-
<h1 style="font-size:20px;font-weight:800;margin:0 0 8px;">Zamówienie ${escapeHtml(ctx.order.number)}</h1>
|
|
79
|
-
<p style="margin:0 0 20px;color:#555566;line-height:1.5;">${escapeHtml(intro)}</p>
|
|
80
|
-
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
|
81
|
-
<thead><tr style="background:#f4f2fa;"><th align="left" style="padding:8px;">Pozycja</th><th style="padding:8px;">Ilość</th><th align="right" style="padding:8px;">Suma</th></tr></thead>
|
|
82
|
-
<tbody>${itemsRows}</tbody>
|
|
83
|
-
</table>
|
|
84
|
-
<div style="margin-top:16px;text-align:right;font-size:14px;line-height:1.7;">
|
|
85
|
-
<div>Wysyłka: <strong>${ctx.order.shippingGross}</strong></div>
|
|
86
|
-
<div style="font-size:16px;">Razem (brutto): <strong style="color:#5B4A9E;">${ctx.order.totalGross}</strong></div>
|
|
87
|
-
<div style="color:#8888A0;font-size:12px;">netto ${ctx.order.totalNet} · VAT ${ctx.order.vatAmount}</div>
|
|
88
|
-
</div>
|
|
89
|
-
${ctx.tracking
|
|
90
|
-
? `<div style="margin-top:20px;padding:16px;background:#F4F2FA;border-radius:10px;font-size:14px;">
|
|
91
|
-
<div style="color:#555566;margin-bottom:4px;">${escapeHtml(ctx.tracking.label)}</div>
|
|
92
|
-
<div style="font-family:ui-monospace,monospace;font-weight:700;word-break:break-all;">${escapeHtml(ctx.tracking.number)}</div>
|
|
93
|
-
${ctx.tracking.url
|
|
94
|
-
? `<a href="${escapeHtml(ctx.tracking.url)}" style="display:inline-block;margin-top:8px;color:#5B4A9E;font-weight:600;text-decoration:none;">${escapeHtml(ctx.tracking.linkLabel)}</a>`
|
|
95
|
-
: ''}
|
|
96
|
-
</div>`
|
|
97
|
-
: ''}
|
|
98
|
-
${ctx.viewUrl
|
|
99
|
-
? `<div style="margin-top:24px;text-align:center;"><a href="${escapeHtml(ctx.viewUrl)}" style="display:inline-block;background:#5B4A9E;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600;">${escapeHtml(ctx.viewLinkLabel)}</a></div>`
|
|
100
|
-
: ''}
|
|
101
|
-
</div>
|
|
102
|
-
<p style="text-align:center;color:#8888A0;font-size:12px;margin-top:16px;">AriaCMS · ${escapeHtml(ctx.order.customerEmail)}</p>
|
|
103
|
-
</div>
|
|
104
|
-
</body></html>`;
|
|
105
|
-
}
|
|
106
|
-
function escapeHtml(s) {
|
|
107
|
-
return s
|
|
108
|
-
.replace(/&/g, '&')
|
|
109
|
-
.replace(/</g, '<')
|
|
110
|
-
.replace(/>/g, '>')
|
|
111
|
-
.replace(/"/g, '"')
|
|
112
|
-
.replace(/'/g, ''');
|
|
38
|
+
function formatPrice(cents, currency) {
|
|
39
|
+
return new Intl.NumberFormat('pl-PL', {
|
|
40
|
+
style: 'currency',
|
|
41
|
+
currency,
|
|
42
|
+
minimumFractionDigits: 2
|
|
43
|
+
}).format(cents / 100);
|
|
113
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* List of template names a shop install must ship in `dist/shop/templates/`.
|
|
47
|
+
* Consumed by `validateBuiltinTemplates` at CMS bootstrap.
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export const REQUIRED_TEMPLATE_NAMES = [
|
|
51
|
+
...Object.values(STATUS_TO_TEMPLATE),
|
|
52
|
+
'low-stock'
|
|
53
|
+
];
|
|
114
54
|
export async function sendOrderStatusEmail(orderId, status) {
|
|
115
55
|
const cms = getCMS();
|
|
116
56
|
const shop = requireShopConfig();
|
|
@@ -123,6 +63,13 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
123
63
|
if (!order)
|
|
124
64
|
return;
|
|
125
65
|
const items = await getOrderItems(orderId);
|
|
66
|
+
let coupon = null;
|
|
67
|
+
try {
|
|
68
|
+
coupon = await getOrderCoupon(orderId);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error('[shop] Failed to load order coupon for email context:', err);
|
|
72
|
+
}
|
|
126
73
|
const lang = (order.language || cms.languages[0] || 'pl');
|
|
127
74
|
const subjectKey = (lang in STATUS_SUBJECTS[status] ? lang : 'pl');
|
|
128
75
|
const viewUrl = /^https?:\/\//i.test(shop.orderViewUrl)
|
|
@@ -155,7 +102,7 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
155
102
|
linkLabel: TRACKING_LABEL[subjectKey].linkLabel
|
|
156
103
|
};
|
|
157
104
|
}
|
|
158
|
-
const
|
|
105
|
+
const context = {
|
|
159
106
|
viewUrl,
|
|
160
107
|
viewLinkLabel: VIEW_LINK_LABEL[subjectKey],
|
|
161
108
|
tracking,
|
|
@@ -164,11 +111,15 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
164
111
|
status: order.status,
|
|
165
112
|
customerName: order.customerName ?? '',
|
|
166
113
|
customerEmail: order.customerEmail,
|
|
114
|
+
language: order.language,
|
|
167
115
|
totalGross: formatPrice(order.totalGross, order.currency),
|
|
168
116
|
totalNet: formatPrice(order.totalNet, order.currency),
|
|
169
117
|
vatAmount: formatPrice(order.vatAmount, order.currency),
|
|
170
118
|
shippingGross: formatPrice(order.shippingGross, order.currency),
|
|
171
|
-
currency: order.currency
|
|
119
|
+
currency: order.currency,
|
|
120
|
+
couponCode: coupon?.code ?? null,
|
|
121
|
+
discountAmount: coupon ? formatPrice(coupon.discountAmount, order.currency) : null,
|
|
122
|
+
hasDiscount: Boolean(coupon && coupon.discountAmount > 0)
|
|
172
123
|
},
|
|
173
124
|
items: items.map((i) => ({
|
|
174
125
|
name: resolveI18n(i.nameSnapshot?.product
|
|
@@ -176,11 +127,25 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
176
127
|
: undefined, lang) || '—',
|
|
177
128
|
qty: i.qty,
|
|
178
129
|
lineGross: formatPrice(i.priceGrossSnapshot * i.qty, order.currency)
|
|
179
|
-
}))
|
|
130
|
+
})),
|
|
131
|
+
shop: {
|
|
132
|
+
currency: order.currency,
|
|
133
|
+
adminEmail: shop.adminEmail ?? null
|
|
134
|
+
}
|
|
180
135
|
};
|
|
181
136
|
const subject = `${STATUS_SUBJECTS[status][subjectKey]} · ${order.number}`;
|
|
182
|
-
const
|
|
183
|
-
|
|
137
|
+
const templateName = STATUS_TO_TEMPLATE[status];
|
|
138
|
+
let html;
|
|
139
|
+
try {
|
|
140
|
+
html = await renderEmailTemplate(templateName, lang, context, {
|
|
141
|
+
projectDir: shop.emailTemplates?.dir,
|
|
142
|
+
strict: shop.emailTemplates?.strict
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`[shop] Failed to render template "${templateName}.${lang}.html" for order ${order.number}:`, err);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
184
149
|
try {
|
|
185
150
|
await emailAdapter.sendMail({
|
|
186
151
|
to: order.customerEmail,
|
|
@@ -196,9 +161,7 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
196
161
|
/**
|
|
197
162
|
* Notify the shop admin that a product crossed its low-stock threshold.
|
|
198
163
|
* Fire-and-forget — silently no-ops when no `shop.adminEmail` or no email
|
|
199
|
-
* adapter is configured.
|
|
200
|
-
* site centralised (HTML, escaping, logging behaviour identical to status
|
|
201
|
-
* emails).
|
|
164
|
+
* adapter is configured.
|
|
202
165
|
*
|
|
203
166
|
* @internal
|
|
204
167
|
*/
|
|
@@ -211,20 +174,31 @@ export async function sendLowStockEmail(input) {
|
|
|
211
174
|
const emailAdapter = cms.emailAdapter;
|
|
212
175
|
if (!emailAdapter)
|
|
213
176
|
return;
|
|
177
|
+
const lang = (cms.languages[0] ?? 'pl');
|
|
214
178
|
const subject = `Niski stan magazynowy · ${input.productTitle}`;
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
179
|
+
const context = {
|
|
180
|
+
product: {
|
|
181
|
+
title: input.productTitle,
|
|
182
|
+
variantSku: input.variantSku,
|
|
183
|
+
stock: input.stock,
|
|
184
|
+
threshold: input.threshold
|
|
185
|
+
},
|
|
186
|
+
shop: {
|
|
187
|
+
currency: shop.currency,
|
|
188
|
+
adminEmail
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
let html;
|
|
192
|
+
try {
|
|
193
|
+
html = await renderEmailTemplate('low-stock', lang, context, {
|
|
194
|
+
projectDir: shop.emailTemplates?.dir,
|
|
195
|
+
strict: shop.emailTemplates?.strict
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.error('[shop] Failed to render low-stock template:', err);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
228
202
|
try {
|
|
229
203
|
await emailAdapter.sendMail({ to: adminEmail, subject, html });
|
|
230
204
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop email template registry — file-based Handlebars templates with
|
|
3
|
+
* 4-step resolution (project override → package default) and per-template
|
|
4
|
+
* CMS singleton auto-fetch. Used by `sendOrderStatusEmail` and
|
|
5
|
+
* `sendLowStockEmail` in email.ts.
|
|
6
|
+
*
|
|
7
|
+
* @internal — exported here for testing; not part of the public API.
|
|
8
|
+
*/
|
|
9
|
+
type Lang = string;
|
|
10
|
+
interface ResolveResult {
|
|
11
|
+
filePath: string;
|
|
12
|
+
source: string;
|
|
13
|
+
isOverride: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 4-step lookup for a single template file.
|
|
17
|
+
* Returns `null` only if every step misses (shouldn't happen for built-in
|
|
18
|
+
* templates if the package is intact).
|
|
19
|
+
*/
|
|
20
|
+
declare function resolveTemplatePath(projectDir: string, name: string, lang: Lang, fallbackLang: Lang): ResolveResult | null;
|
|
21
|
+
/**
|
|
22
|
+
* Traverse Handlebars AST and collect all `cms.<slug>.*` accessor slugs.
|
|
23
|
+
* Returns the unique set so the renderer can prefetch them.
|
|
24
|
+
*/
|
|
25
|
+
declare function extractCmsSlugs(ast: hbs.AST.Program): Set<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Public render entry — used by sendOrderStatusEmail / sendLowStockEmail.
|
|
28
|
+
* Looks up the template, prefetches required CMS singletons, renders.
|
|
29
|
+
*/
|
|
30
|
+
export declare function renderEmailTemplate(name: string, lang: Lang, context: Record<string, unknown>, opts?: {
|
|
31
|
+
projectDir?: string;
|
|
32
|
+
strict?: boolean;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Bootstrap-time smoke test — verify every required default template is
|
|
36
|
+
* present in the package. Called from initCMS after setShop.
|
|
37
|
+
* Lookup-only: does not compile or render, so cheap.
|
|
38
|
+
*/
|
|
39
|
+
export declare function validateBuiltinTemplates(requiredNames: string[], defaultLang: Lang): void;
|
|
40
|
+
/** @internal — exposed for tests */
|
|
41
|
+
export declare const __testExports: {
|
|
42
|
+
extractCmsSlugs: typeof extractCmsSlugs;
|
|
43
|
+
resolveTemplatePath: typeof resolveTemplatePath;
|
|
44
|
+
PACKAGE_TEMPLATES_DIR: string;
|
|
45
|
+
clearCache: () => void;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop email template registry — file-based Handlebars templates with
|
|
3
|
+
* 4-step resolution (project override → package default) and per-template
|
|
4
|
+
* CMS singleton auto-fetch. Used by `sendOrderStatusEmail` and
|
|
5
|
+
* `sendLowStockEmail` in email.ts.
|
|
6
|
+
*
|
|
7
|
+
* @internal — exported here for testing; not part of the public API.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import Handlebars from 'handlebars';
|
|
13
|
+
import { getCMS } from '../../core/cms.js';
|
|
14
|
+
import { resolveEntry } from '../../sveltekit/server/index.js';
|
|
15
|
+
const PACKAGE_TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
let helpersRegistered = false;
|
|
18
|
+
const partialsRegisteredForLang = new Set();
|
|
19
|
+
function isDev() {
|
|
20
|
+
return process.env.NODE_ENV !== 'production';
|
|
21
|
+
}
|
|
22
|
+
function registerHelpers() {
|
|
23
|
+
if (helpersRegistered)
|
|
24
|
+
return;
|
|
25
|
+
Handlebars.registerHelper('currency', (value, currency) => {
|
|
26
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
27
|
+
const cur = typeof currency === 'string' ? currency : 'PLN';
|
|
28
|
+
if (!Number.isFinite(num))
|
|
29
|
+
return '';
|
|
30
|
+
return new Intl.NumberFormat('pl-PL', {
|
|
31
|
+
style: 'currency',
|
|
32
|
+
currency: cur,
|
|
33
|
+
minimumFractionDigits: 2
|
|
34
|
+
}).format(num / 100);
|
|
35
|
+
});
|
|
36
|
+
Handlebars.registerHelper('date', (value, format) => {
|
|
37
|
+
if (value == null)
|
|
38
|
+
return '';
|
|
39
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
40
|
+
if (Number.isNaN(d.getTime()))
|
|
41
|
+
return '';
|
|
42
|
+
const fmt = format === 'long' ? 'long' : 'short';
|
|
43
|
+
if (fmt === 'long') {
|
|
44
|
+
return new Intl.DateTimeFormat('pl-PL', {
|
|
45
|
+
day: 'numeric',
|
|
46
|
+
month: 'long',
|
|
47
|
+
year: 'numeric'
|
|
48
|
+
}).format(d);
|
|
49
|
+
}
|
|
50
|
+
return new Intl.DateTimeFormat('pl-PL').format(d);
|
|
51
|
+
});
|
|
52
|
+
Handlebars.registerHelper('eq', (a, b) => a === b);
|
|
53
|
+
helpersRegistered = true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 4-step lookup for a single template file.
|
|
57
|
+
* Returns `null` only if every step misses (shouldn't happen for built-in
|
|
58
|
+
* templates if the package is intact).
|
|
59
|
+
*/
|
|
60
|
+
function resolveTemplatePath(projectDir, name, lang, fallbackLang) {
|
|
61
|
+
const candidates = [
|
|
62
|
+
{ filePath: path.join(projectDir, `${name}.${lang}.html`), isOverride: true },
|
|
63
|
+
{ filePath: path.join(projectDir, `${name}.html`), isOverride: true },
|
|
64
|
+
{ filePath: path.join(PACKAGE_TEMPLATES_DIR, `${name}.${lang}.html`), isOverride: false },
|
|
65
|
+
{ filePath: path.join(PACKAGE_TEMPLATES_DIR, `${name}.${fallbackLang}.html`), isOverride: false }
|
|
66
|
+
];
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
const source = fs.readFileSync(c.filePath, 'utf8');
|
|
70
|
+
return { filePath: c.filePath, source, isOverride: c.isOverride };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// missing or unreadable, try next
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Traverse Handlebars AST and collect all `cms.<slug>.*` accessor slugs.
|
|
80
|
+
* Returns the unique set so the renderer can prefetch them.
|
|
81
|
+
*/
|
|
82
|
+
function extractCmsSlugs(ast) {
|
|
83
|
+
const slugs = new Set();
|
|
84
|
+
const visit = (node) => {
|
|
85
|
+
if (!node || typeof node !== 'object')
|
|
86
|
+
return;
|
|
87
|
+
const type = node.type;
|
|
88
|
+
if (type === 'PathExpression') {
|
|
89
|
+
const parts = node.parts;
|
|
90
|
+
if (parts?.length >= 2 && parts[0] === 'cms') {
|
|
91
|
+
slugs.add(parts[1]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Recurse common AST child fields
|
|
95
|
+
const obj = node;
|
|
96
|
+
for (const key of [
|
|
97
|
+
'body',
|
|
98
|
+
'path',
|
|
99
|
+
'params',
|
|
100
|
+
'hash',
|
|
101
|
+
'pairs',
|
|
102
|
+
'value',
|
|
103
|
+
'program',
|
|
104
|
+
'inverse',
|
|
105
|
+
'expression'
|
|
106
|
+
]) {
|
|
107
|
+
const child = obj[key];
|
|
108
|
+
if (Array.isArray(child)) {
|
|
109
|
+
child.forEach((c) => visit(c));
|
|
110
|
+
}
|
|
111
|
+
else if (child && typeof child === 'object') {
|
|
112
|
+
visit(child);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
visit(ast);
|
|
117
|
+
return slugs;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Register all partials for a given lang from `_partials/` (project override
|
|
121
|
+
* before package default). Idempotent in prod, re-registered each call in dev
|
|
122
|
+
* to pick up file changes.
|
|
123
|
+
*/
|
|
124
|
+
function registerPartialsForLang(projectDir, lang, fallbackLang) {
|
|
125
|
+
const cacheKey = `${projectDir}|${lang}|${fallbackLang}`;
|
|
126
|
+
if (!isDev() && partialsRegisteredForLang.has(cacheKey))
|
|
127
|
+
return;
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const dirs = [
|
|
130
|
+
path.join(projectDir, '_partials'),
|
|
131
|
+
path.join(PACKAGE_TEMPLATES_DIR, '_partials')
|
|
132
|
+
];
|
|
133
|
+
for (const dir of dirs) {
|
|
134
|
+
let entries;
|
|
135
|
+
try {
|
|
136
|
+
entries = fs.readdirSync(dir);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const file of entries) {
|
|
142
|
+
// Match <name>.<lang>.html first, then <name>.<fallbackLang>.html
|
|
143
|
+
const match = file.match(/^(.+)\.([^.]+)\.html$/);
|
|
144
|
+
if (!match)
|
|
145
|
+
continue;
|
|
146
|
+
const [, partialName, fileLang] = match;
|
|
147
|
+
if (fileLang !== lang && fileLang !== fallbackLang)
|
|
148
|
+
continue;
|
|
149
|
+
// Prefer requested lang over fallback, prefer project over package
|
|
150
|
+
const registryKey = `${partialName}|${fileLang}`;
|
|
151
|
+
if (seen.has(registryKey))
|
|
152
|
+
continue;
|
|
153
|
+
seen.add(registryKey);
|
|
154
|
+
// Only register one per partialName — prefer lang match
|
|
155
|
+
if (Handlebars.partials[partialName] && fileLang !== lang)
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
const source = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
159
|
+
Handlebars.registerPartial(partialName, source);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// skip unreadable
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!isDev())
|
|
167
|
+
partialsRegisteredForLang.add(cacheKey);
|
|
168
|
+
}
|
|
169
|
+
function compileOrThrow(source, filePath, strict) {
|
|
170
|
+
const ast = Handlebars.parse(source);
|
|
171
|
+
const cmsSlugs = extractCmsSlugs(ast);
|
|
172
|
+
const compiled = Handlebars.compile(source, { noEscape: false, strict });
|
|
173
|
+
// Touch filePath in any thrown error context
|
|
174
|
+
void filePath;
|
|
175
|
+
return { compiled, cmsSlugs, source };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Fetch all required CMS singletons in parallel and shape them as
|
|
179
|
+
* `{ <slug>: entryData }` for template context.
|
|
180
|
+
*/
|
|
181
|
+
async function resolveCmsContext(slugs) {
|
|
182
|
+
if (slugs.size === 0)
|
|
183
|
+
return {};
|
|
184
|
+
const slugList = Array.from(slugs);
|
|
185
|
+
const entries = await Promise.all(slugList.map(async (slug) => {
|
|
186
|
+
try {
|
|
187
|
+
const entry = await resolveEntry({ collection: slug });
|
|
188
|
+
return [slug, entry?.data ?? {}];
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return [slug, {}];
|
|
192
|
+
}
|
|
193
|
+
}));
|
|
194
|
+
return Object.fromEntries(entries);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get a compiled template (cached in prod, re-compiled each call in dev).
|
|
198
|
+
* Falls back through the 4-step lookup. Throws if nothing is found.
|
|
199
|
+
*/
|
|
200
|
+
function getCompiled(name, lang, opts) {
|
|
201
|
+
const key = `${opts.projectDir}|${name}|${lang}|${opts.fallbackLang}`;
|
|
202
|
+
if (!isDev()) {
|
|
203
|
+
const hit = cache.get(key);
|
|
204
|
+
if (hit)
|
|
205
|
+
return { ...hit, isOverride: false };
|
|
206
|
+
}
|
|
207
|
+
const resolved = resolveTemplatePath(opts.projectDir, name, lang, opts.fallbackLang);
|
|
208
|
+
if (!resolved) {
|
|
209
|
+
throw new Error(`[shop] No template found for "${name}" (lang=${lang}, fallback=${opts.fallbackLang}). ` +
|
|
210
|
+
`Looked in project dir "${opts.projectDir}" and package defaults. ` +
|
|
211
|
+
`This is likely a corrupt install or a misconfigured shop.emailTemplates.dir.`);
|
|
212
|
+
}
|
|
213
|
+
const strict = opts.strict === true;
|
|
214
|
+
let entry;
|
|
215
|
+
try {
|
|
216
|
+
entry = compileOrThrow(resolved.source, resolved.filePath, strict);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
// If the failing file was a project override, fall back to package default.
|
|
220
|
+
console.error(`[shop] Template compile failed at ${resolved.filePath}:`, err instanceof Error ? err.message : err);
|
|
221
|
+
if (resolved.isOverride) {
|
|
222
|
+
const pkgPath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${lang}.html`);
|
|
223
|
+
const fallbackPath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${opts.fallbackLang}.html`);
|
|
224
|
+
for (const p of [pkgPath, fallbackPath]) {
|
|
225
|
+
try {
|
|
226
|
+
const src = fs.readFileSync(p, 'utf8');
|
|
227
|
+
entry = compileOrThrow(src, p, strict);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// continue
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!entry)
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
if (!isDev())
|
|
239
|
+
cache.set(key, entry);
|
|
240
|
+
return { ...entry, isOverride: resolved.isOverride };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Public render entry — used by sendOrderStatusEmail / sendLowStockEmail.
|
|
244
|
+
* Looks up the template, prefetches required CMS singletons, renders.
|
|
245
|
+
*/
|
|
246
|
+
export async function renderEmailTemplate(name, lang, context, opts = {}) {
|
|
247
|
+
registerHelpers();
|
|
248
|
+
const cms = getCMS();
|
|
249
|
+
const fallbackLang = cms.languages[0] ?? 'pl';
|
|
250
|
+
const projectDir = path.resolve(opts.projectDir ?? path.join(process.cwd(), 'src/emails/shop'));
|
|
251
|
+
registerPartialsForLang(projectDir, lang, fallbackLang);
|
|
252
|
+
const entry = getCompiled(name, lang, { projectDir, fallbackLang, strict: opts.strict });
|
|
253
|
+
const cmsContext = await resolveCmsContext(entry.cmsSlugs);
|
|
254
|
+
const fullContext = { ...context, cms: cmsContext };
|
|
255
|
+
// strict/noEscape are compile-time options, already baked into entry.compiled.
|
|
256
|
+
return entry.compiled(fullContext);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Bootstrap-time smoke test — verify every required default template is
|
|
260
|
+
* present in the package. Called from initCMS after setShop.
|
|
261
|
+
* Lookup-only: does not compile or render, so cheap.
|
|
262
|
+
*/
|
|
263
|
+
export function validateBuiltinTemplates(requiredNames, defaultLang) {
|
|
264
|
+
const missing = [];
|
|
265
|
+
for (const name of requiredNames) {
|
|
266
|
+
const filePath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${defaultLang}.html`);
|
|
267
|
+
if (!fs.existsSync(filePath))
|
|
268
|
+
missing.push(filePath);
|
|
269
|
+
}
|
|
270
|
+
if (missing.length > 0) {
|
|
271
|
+
throw new Error(`[shop] Built-in email templates missing — likely a corrupt install. ` +
|
|
272
|
+
`Reinstall includio-cms. Missing:\n ${missing.join('\n ')}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** @internal — exposed for tests */
|
|
276
|
+
export const __testExports = {
|
|
277
|
+
extractCmsSlugs,
|
|
278
|
+
resolveTemplatePath,
|
|
279
|
+
PACKAGE_TEMPLATES_DIR,
|
|
280
|
+
clearCache: () => {
|
|
281
|
+
cache.clear();
|
|
282
|
+
partialsRegisteredForLang.clear();
|
|
283
|
+
// Reset Handlebars partial registry to avoid cross-test bleed.
|
|
284
|
+
for (const k of Object.keys(Handlebars.partials)) {
|
|
285
|
+
Handlebars.unregisterPartial(k);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { shopInvoicesTable, type ShopInvoiceStatus } from '../../db-postgres/schema/shop/index.js';
|
|
2
|
+
import type { Currency, InvoiceIssuePolicy, InvoicePayload, OrderStatus } from '../types.js';
|
|
3
|
+
type InvoiceRow = typeof shopInvoicesTable.$inferSelect;
|
|
4
|
+
/** Order fields the trigger / idempotency decision depends on. */
|
|
5
|
+
export interface InvoiceOrderState {
|
|
6
|
+
status: OrderStatus;
|
|
7
|
+
balanceOwed: boolean;
|
|
8
|
+
customerNip: string | null;
|
|
9
|
+
invoiceRequested: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Order fields needed to build the invoice payload. */
|
|
12
|
+
export interface InvoiceOrderData {
|
|
13
|
+
number: string;
|
|
14
|
+
currency: Currency;
|
|
15
|
+
customerEmail: string;
|
|
16
|
+
customerName: string | null;
|
|
17
|
+
customerNip: string | null;
|
|
18
|
+
customerCompanyName: string | null;
|
|
19
|
+
shippingAddress: Record<string, string> | null;
|
|
20
|
+
billingAddress: Record<string, string> | null;
|
|
21
|
+
language: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface InvoiceItemData {
|
|
24
|
+
nameSnapshot: Record<string, string>;
|
|
25
|
+
qty: number;
|
|
26
|
+
priceGrossSnapshot: number;
|
|
27
|
+
vatRate: number;
|
|
28
|
+
}
|
|
29
|
+
export type InvoiceAction = 'skip' | 'create' | 'resend';
|
|
30
|
+
export declare class InvoiceError extends Error {
|
|
31
|
+
readonly code: string;
|
|
32
|
+
constructor(code: string, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** Does the order qualify for an automatic invoice under `policy`? Pure. */
|
|
35
|
+
export declare function shouldIssueInvoice(order: InvoiceOrderState, policy: InvoiceIssuePolicy): boolean;
|
|
36
|
+
/** Fully paid = a paid-or-later status with no outstanding balance. Pure. */
|
|
37
|
+
export declare function isOrderFullyPaid(order: InvoiceOrderState): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Decide what to do with an order's invoice given any existing record. Pure —
|
|
40
|
+
* encodes the trigger + idempotency rules so they can be tested without a DB.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decideInvoiceAction(order: InvoiceOrderState, existing: {
|
|
43
|
+
status: ShopInvoiceStatus;
|
|
44
|
+
} | null, policy: InvoiceIssuePolicy, opts?: {
|
|
45
|
+
force?: boolean;
|
|
46
|
+
}): InvoiceAction;
|
|
47
|
+
/** Map order + items onto the provider-agnostic {@link InvoicePayload}. Pure. */
|
|
48
|
+
export declare function buildInvoicePayload(order: InvoiceOrderData, items: InvoiceItemData[], paidAt: string): InvoicePayload;
|
|
49
|
+
export declare function getInvoiceByOrderId(orderId: string): Promise<InvoiceRow | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Issue an invoice for an order if it qualifies. Fire-and-forget, fail-open —
|
|
52
|
+
* never throws, so a failing invoicing provider can't block the payment webhook.
|
|
53
|
+
* Called from `updateOrderStatus` (on `paid`) and `markBalancePaid`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function maybeIssueInvoiceForOrder(orderId: string): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Issue (or retry/resend) an invoice on demand from the admin. Throws on error
|
|
58
|
+
* so the caller can surface it. `force` bypasses the trigger policy and allows
|
|
59
|
+
* re-sending an already-issued invoice.
|
|
60
|
+
*/
|
|
61
|
+
export declare function issueInvoiceForOrder(orderId: string, opts?: {
|
|
62
|
+
force?: boolean;
|
|
63
|
+
}): Promise<InvoiceRow | null>;
|
|
64
|
+
export {};
|