includio-cms 0.28.0 → 0.34.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.
Files changed (117) hide show
  1. package/API.md +39 -13
  2. package/CHANGELOG.md +19 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +1 -0
  5. package/dist/admin/api/handler.js +4 -0
  6. package/dist/admin/api/integrations.d.ts +13 -0
  7. package/dist/admin/api/integrations.js +61 -0
  8. package/dist/admin/api/test-email.d.ts +9 -0
  9. package/dist/admin/api/test-email.js +39 -0
  10. package/dist/admin/auth-client.d.ts +2209 -2209
  11. package/dist/admin/client/index.d.ts +10 -0
  12. package/dist/admin/client/index.js +12 -0
  13. package/dist/admin/client/maintenance/maintenance-page.svelte +210 -0
  14. package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
  15. package/dist/admin/client/shop/restore-order-cell.svelte +29 -0
  16. package/dist/admin/client/shop/restore-order-cell.svelte.d.ts +8 -0
  17. package/dist/admin/client/shop/shop-order-detail-page.svelte +71 -1
  18. package/dist/admin/client/shop/shop-orders-list-page.svelte +113 -53
  19. package/dist/admin/components/layout/app-sidebar.svelte +2 -0
  20. package/dist/admin/components/layout/nav-custom.svelte +26 -0
  21. package/dist/admin/components/layout/nav-custom.svelte.d.ts +3 -0
  22. package/dist/admin/components/layout/page-header.svelte +13 -3
  23. package/dist/admin/components/layout/page-header.svelte.d.ts +13 -3
  24. package/dist/admin/remote/admin.remote.d.ts +7 -0
  25. package/dist/admin/remote/admin.remote.js +10 -0
  26. package/dist/admin/remote/entry.remote.d.ts +4 -4
  27. package/dist/admin/remote/index.d.ts +1 -0
  28. package/dist/admin/remote/index.js +1 -0
  29. package/dist/admin/remote/invite.d.ts +2 -2
  30. package/dist/admin/remote/shop.remote.d.ts +75 -48
  31. package/dist/admin/remote/shop.remote.js +41 -10
  32. package/dist/admin/types.d.ts +15 -0
  33. package/dist/admin/utils/csv-export.d.ts +45 -0
  34. package/dist/admin/utils/csv-export.js +61 -0
  35. package/dist/cli/scaffold/admin.js +1 -1
  36. package/dist/components/ui/button-group/button-group-separator.svelte.d.ts +1 -1
  37. package/dist/components/ui/command/command.svelte.d.ts +1 -1
  38. package/dist/components/ui/field/field-label.svelte.d.ts +1 -1
  39. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  40. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  41. package/dist/components/ui/item/item-separator.svelte.d.ts +1 -1
  42. package/dist/components/ui/select/select-group-heading.svelte.d.ts +1 -1
  43. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  44. package/dist/components/ui/sidebar/sidebar-separator.svelte.d.ts +1 -1
  45. package/dist/core/cms.d.ts +44 -2
  46. package/dist/core/cms.js +64 -0
  47. package/dist/core/index.d.ts +1 -4
  48. package/dist/core/index.js +4 -4
  49. package/dist/core/server/index.d.ts +4 -1
  50. package/dist/core/server/index.js +4 -1
  51. package/dist/db-postgres/schema/shop/order.d.ts +34 -0
  52. package/dist/db-postgres/schema/shop/order.js +4 -0
  53. package/dist/paraglide/messages/_index.d.ts +3 -36
  54. package/dist/paraglide/messages/_index.js +3 -71
  55. package/dist/paraglide/messages/hello_world.d.ts +5 -0
  56. package/dist/paraglide/messages/hello_world.js +33 -0
  57. package/dist/paraglide/messages/login_hello.d.ts +16 -0
  58. package/dist/paraglide/messages/login_hello.js +34 -0
  59. package/dist/paraglide/messages/login_please_login.d.ts +16 -0
  60. package/dist/paraglide/messages/login_please_login.js +34 -0
  61. package/dist/shop/adapters/fakturownia/client.d.ts +5 -0
  62. package/dist/shop/adapters/fakturownia/client.js +20 -0
  63. package/dist/shop/adapters/fakturownia/index.js +11 -0
  64. package/dist/shop/adapters/payu/index.js +11 -0
  65. package/dist/shop/index.d.ts +1 -1
  66. package/dist/shop/server/coupons.d.ts +10 -0
  67. package/dist/shop/server/coupons.js +19 -0
  68. package/dist/shop/server/email.d.ts +7 -3
  69. package/dist/shop/server/email.js +86 -112
  70. package/dist/shop/server/emailTemplateRegistry.d.ts +47 -0
  71. package/dist/shop/server/emailTemplateRegistry.js +288 -0
  72. package/dist/shop/server/orders.d.ts +60 -1
  73. package/dist/shop/server/orders.js +145 -16
  74. package/dist/shop/templates/_partials/footer.en.html +4 -0
  75. package/dist/shop/templates/_partials/footer.pl.html +4 -0
  76. package/dist/shop/templates/_partials/header.en.html +4 -0
  77. package/dist/shop/templates/_partials/header.pl.html +4 -0
  78. package/dist/shop/templates/_partials/items.en.html +14 -0
  79. package/dist/shop/templates/_partials/items.pl.html +14 -0
  80. package/dist/shop/templates/_partials/tracking.en.html +7 -0
  81. package/dist/shop/templates/_partials/tracking.pl.html +7 -0
  82. package/dist/shop/templates/awaiting-payment.en.html +6 -0
  83. package/dist/shop/templates/awaiting-payment.pl.html +6 -0
  84. package/dist/shop/templates/cancelled.en.html +6 -0
  85. package/dist/shop/templates/cancelled.pl.html +6 -0
  86. package/dist/shop/templates/low-stock.en.html +14 -0
  87. package/dist/shop/templates/low-stock.pl.html +14 -0
  88. package/dist/shop/templates/order-completed.en.html +6 -0
  89. package/dist/shop/templates/order-completed.pl.html +6 -0
  90. package/dist/shop/templates/order-received.en.html +7 -0
  91. package/dist/shop/templates/order-received.pl.html +7 -0
  92. package/dist/shop/templates/payment-received.en.html +7 -0
  93. package/dist/shop/templates/payment-received.pl.html +7 -0
  94. package/dist/shop/templates/payment-rejected.en.html +6 -0
  95. package/dist/shop/templates/payment-rejected.pl.html +6 -0
  96. package/dist/shop/templates/preparing.en.html +7 -0
  97. package/dist/shop/templates/preparing.pl.html +7 -0
  98. package/dist/shop/templates/refunded.en.html +6 -0
  99. package/dist/shop/templates/refunded.pl.html +6 -0
  100. package/dist/shop/templates/shipped.en.html +7 -0
  101. package/dist/shop/templates/shipped.pl.html +7 -0
  102. package/dist/shop/types.d.ts +63 -0
  103. package/dist/sveltekit/index.d.ts +0 -1
  104. package/dist/sveltekit/index.js +0 -1
  105. package/dist/sveltekit/server/index.d.ts +2 -0
  106. package/dist/sveltekit/server/index.js +4 -0
  107. package/dist/types/adapters/email.d.ts +13 -0
  108. package/dist/types/cms.d.ts +30 -0
  109. package/dist/types/index.d.ts +1 -1
  110. package/dist/updates/0.34.0/index.d.ts +2 -0
  111. package/dist/updates/0.34.0/index.js +17 -0
  112. package/dist/updates/index.js +3 -1
  113. package/package.json +7 -2
  114. package/dist/paraglide/messages/en.d.ts +0 -5
  115. package/dist/paraglide/messages/en.js +0 -14
  116. package/dist/paraglide/messages/pl.d.ts +0 -5
  117. package/dist/paraglide/messages/pl.js +0 -14
@@ -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
- const TRACKING_LABEL = {
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 STATUS_INTRO = {
29
- new: {
30
- pl: 'Dziękujemy! Otrzymaliśmy Twoje zamówienie i wkrótce je przetworzymy.',
31
- en: 'Thanks! We received your order and will process it shortly.'
32
- },
33
- awaitingPayment: {
34
- pl: 'Otrzymaliśmy zamówienie. Czekamy na płatność zgodnie z wybraną metodą.',
35
- en: 'Your order has been placed. Waiting for payment per the chosen method.'
36
- },
37
- paid: {
38
- pl: 'Płatność została zaksięgowana. Przekazujemy zamówienie do realizacji.',
39
- en: 'Payment has been received. Your order is moving to fulfilment.'
40
- },
41
- preparing: {
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 renderHtml(ctx, intro) {
71
- const itemsRows = ctx.items
72
- .map((i) => `<tr><td style="padding:6px 8px;border-bottom:1px solid #eee;">${escapeHtml(i.name)}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:center;">${i.qty}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:right;">${i.lineGross}</td></tr>`)
73
- .join('');
74
- return `<!doctype html>
75
- <html><body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:#f5f5f8;color:#1a1a2e;">
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, '&amp;')
109
- .replace(/</g, '&lt;')
110
- .replace(/>/g, '&gt;')
111
- .replace(/"/g, '&quot;')
112
- .replace(/'/g, '&#39;');
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 ctx = {
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 intro = STATUS_INTRO[status][subjectKey];
183
- const html = renderHtml(ctx, intro);
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. Stays inside email.ts to keep the adapter call
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 html = `<!doctype html>
216
- <html><body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:#f5f5f8;color:#1a1a2e;">
217
- <div style="max-width:560px;margin:0 auto;padding:24px;">
218
- <div style="background:#fff;border-radius:12px;padding:28px;">
219
- <h1 style="font-size:20px;font-weight:800;margin:0 0 8px;color:#C4893A;">Niski stan magazynowy</h1>
220
- <p style="margin:0 0 16px;color:#555566;line-height:1.5;">
221
- Produkt <strong>${escapeHtml(input.productTitle)}</strong>${input.variantSku ? ` (SKU ${escapeHtml(input.variantSku)})` : ''}
222
- ma już tylko <strong>${input.stock}</strong> sztuk w magazynie (próg alertu: ${input.threshold}).
223
- </p>
224
- <p style="margin:0;color:#8888A0;font-size:13px;">Uzupełnij stan w panelu admina, żeby uniknąć przerwy w sprzedaży.</p>
225
- </div>
226
- </div>
227
- </body></html>`;
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
+ };
@@ -13,6 +13,43 @@ export declare class MixedPaymentPolicyError extends Error {
13
13
  readonly code = "MIXED_PAYMENT_POLICY";
14
14
  constructor(message?: string);
15
15
  }
16
+ /**
17
+ * @public
18
+ * Order statuses an admin is allowed to soft-delete (hide from the admin list).
19
+ * Restricted to states that never carry a settled payment or an issued invoice,
20
+ * so hiding one can't bury accounting/audit data. Paid-or-later and `refunded`
21
+ * orders are never deletable. The invoice guard in `softDeleteOrder` is the
22
+ * second line of defence.
23
+ */
24
+ export declare const DELETABLE_ORDER_STATUSES: Set<OrderStatus>;
25
+ /** @public Pure status-level deletability check (no DB / invoice lookup). */
26
+ export declare function isOrderDeletable(status: OrderStatus): boolean;
27
+ export type OrderDeletionDecision = {
28
+ ok: true;
29
+ } | {
30
+ ok: false;
31
+ reason: 'status' | 'invoice';
32
+ };
33
+ /**
34
+ * @public
35
+ * Pure decision: may this order be soft-deleted? Encodes both guards (status +
36
+ * existing invoice) so they're testable without a DB. `invoice` is the order's
37
+ * current invoice record (or null). An `issued`/`sent` invoice hard-blocks the
38
+ * delete; `pending`/`failed` invoices don't (no legal document was produced).
39
+ */
40
+ export declare function decideOrderDeletion(status: OrderStatus, invoice: {
41
+ status: 'pending' | 'issued' | 'sent' | 'failed';
42
+ } | null): OrderDeletionDecision;
43
+ /**
44
+ * @public
45
+ * Thrown by `softDeleteOrder` when the order can't be hidden: either its status
46
+ * isn't in {@link DELETABLE_ORDER_STATUSES} or it already has an issued invoice.
47
+ */
48
+ export declare class OrderNotDeletableError extends Error {
49
+ readonly reason: 'status' | 'invoice';
50
+ readonly code = "ORDER_NOT_DELETABLE";
51
+ constructor(reason: 'status' | 'invoice');
52
+ }
16
53
  export type OrderRow = typeof shopOrdersTable.$inferSelect;
17
54
  export type OrderItemRow = typeof shopOrderItemsTable.$inferSelect;
18
55
  export type OrderStatusHistoryRow = typeof shopOrderStatusHistoryTable.$inferSelect;
@@ -87,9 +124,31 @@ export declare function getOrderItems(orderId: string): Promise<OrderItemRow[]>;
87
124
  export declare function getOrderStatusHistory(orderId: string): Promise<OrderStatusHistoryRow[]>;
88
125
  export interface ListOrdersOptions {
89
126
  status?: OrderStatus;
90
- email?: string;
127
+ search?: string;
91
128
  limit?: number;
92
129
  offset?: number;
130
+ /**
131
+ * Soft-delete visibility. `'exclude'` (default) hides soft-deleted orders —
132
+ * the normal admin/customer list. `'only'` returns just the trash; `'include'`
133
+ * returns everything regardless of `deletedAt`.
134
+ */
135
+ deleted?: 'exclude' | 'only' | 'include';
93
136
  }
94
137
  export declare function listOrders(opts?: ListOrdersOptions): Promise<OrderRow[]>;
95
138
  export declare function countOrders(opts?: Omit<ListOrdersOptions, 'limit' | 'offset'>): Promise<number>;
139
+ /**
140
+ * @public
141
+ * Soft-delete an order: hide it from the admin/customer list without removing
142
+ * the row (accounting/audit safety). Idempotent — a no-op on an already-deleted
143
+ * order. Guards on {@link decideOrderDeletion}: throws {@link OrderNotDeletableError}
144
+ * when the status isn't deletable or an issued/sent invoice exists. Releases any
145
+ * active stock reservation immediately so a hidden, abandoned order never locks
146
+ * stock waiting for the TTL.
147
+ */
148
+ export declare function softDeleteOrder(orderId: string, deletedBy: string): Promise<OrderRow>;
149
+ /**
150
+ * @public
151
+ * Restore a soft-deleted order back to the visible list. Idempotent — a no-op
152
+ * on an order that isn't deleted.
153
+ */
154
+ export declare function restoreOrder(orderId: string): Promise<OrderRow>;