@jarwizz/create-jarshop 0.1.4 → 0.1.5

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 (19) hide show
  1. package/dist/template/src/email-smoke.ts +1 -0
  2. package/dist/template/src/migrations/1787800000000-jarshop-order-email-event-key.ts +60 -0
  3. package/dist/template/src/migrations/1787900000000-jarshop-email-brand-configuration.ts +32 -0
  4. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +213 -32
  5. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +17 -0
  6. package/dist/template/src/plugins/jarshop-checkout/index.ts +49 -1
  7. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +146 -5
  8. package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.entity.ts +61 -0
  9. package/dist/template/src/plugins/jarshop-checkout/order-email-configuration.ts +190 -0
  10. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +321 -13
  11. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +3 -1
  12. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +15 -4
  13. package/dist/template/src/plugins/jarshop-checkout/order-email-lifecycle.ts +335 -0
  14. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +308 -15
  15. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +86 -8
  16. package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-core.ts +131 -0
  17. package/dist/template/src/plugins/jarshop-checkout/order-lifecycle-process.ts +102 -0
  18. package/dist/template-manifest.json +1 -1
  19. package/package.json +1 -1
@@ -1,5 +1,15 @@
1
1
  import { mkdir, rename, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { JarShopEmailBrandConfiguration } from "./order-email-configuration.entity.js";
4
+ import {
5
+ createTrackingUrl,
6
+ resolveShippingEmailConfiguration,
7
+ } from "./order-email-configuration.js";
8
+ import type {
9
+ FulfillmentLifecycleEmailKind,
10
+ FulfillmentLifecycleEmailSnapshot,
11
+ OrderCancellationEmailSnapshot,
12
+ } from "./order-email-outbox.js";
3
13
 
4
14
  const fileTransport = "file";
5
15
  const defaultStorefrontUrl = "http://localhost:3001";
@@ -11,6 +21,7 @@ export type OrderConfirmationEmail = {
11
21
  recipient: string;
12
22
  subject: string;
13
23
  text: string;
24
+ html: string;
14
25
  };
15
26
 
16
27
  export type OrderEmailTransport = {
@@ -55,8 +66,21 @@ export type OrderConfirmationEmailInput = {
55
66
  quantity: number;
56
67
  totalWithTax: number;
57
68
  }>;
69
+ brandConfiguration?: JarShopEmailBrandConfiguration;
58
70
  };
59
71
 
72
+ export type OrderLifecycleEmailInput =
73
+ | {
74
+ kind: FulfillmentLifecycleEmailKind;
75
+ snapshot: FulfillmentLifecycleEmailSnapshot;
76
+ brandConfiguration: JarShopEmailBrandConfiguration;
77
+ }
78
+ | {
79
+ kind: "order-cancelled";
80
+ snapshot: OrderCancellationEmailSnapshot;
81
+ brandConfiguration: JarShopEmailBrandConfiguration;
82
+ };
83
+
60
84
  export const orderEmailDeliveryPolicy = {
61
85
  maximumAttempts: 5,
62
86
  leaseDurationMs: 2 * 60 * 1_000,
@@ -182,6 +206,7 @@ export function createSmtpOrderEmailTransport(
182
206
  to: string;
183
207
  subject: string;
184
208
  text: string;
209
+ html: string;
185
210
  }) => Promise<unknown>,
186
211
  ): OrderEmailTransport {
187
212
  return {
@@ -192,6 +217,7 @@ export function createSmtpOrderEmailTransport(
192
217
  to: email.recipient,
193
218
  subject: email.subject,
194
219
  text: email.text,
220
+ html: email.html,
195
221
  });
196
222
  },
197
223
  };
@@ -210,22 +236,104 @@ export function renderOrderConfirmationEmail(
210
236
  const lines = input.lines.map(
211
237
  (line) => `- ${line.quantity}× ${line.name}: ${money(line.totalWithTax)}`,
212
238
  );
239
+ const brand = input.brandConfiguration ?? defaultBrandConfiguration;
213
240
 
241
+ const text = [
242
+ `${copy.greeting} #${input.orderCode}.`,
243
+ "",
244
+ copy.items,
245
+ ...lines,
246
+ "",
247
+ `${copy.shipping}: ${input.shippingMethod || copy.notAvailable} (${money(input.shippingWithTax)})`,
248
+ `${copy.payment}: ${input.paymentMethod || copy.notAvailable}`,
249
+ `${copy.total}: ${money(input.totalWithTax)}`,
250
+ "",
251
+ `${copy.confirmation}: ${input.confirmationUrl}`,
252
+ "",
253
+ ...renderTextBrandFooter(brand),
254
+ ].join("\n");
214
255
  return {
215
256
  recipient: input.recipient,
216
257
  subject: `${copy.subject} #${input.orderCode}`,
217
- text: [
218
- `${copy.greeting} #${input.orderCode}.`,
219
- "",
220
- copy.items,
221
- ...lines,
222
- "",
223
- `${copy.shipping}: ${input.shippingMethod || copy.notAvailable} (${money(input.shippingWithTax)})`,
224
- `${copy.payment}: ${input.paymentMethod || copy.notAvailable}`,
225
- `${copy.total}: ${money(input.totalWithTax)}`,
226
- "",
227
- `${copy.confirmation}: ${input.confirmationUrl}`,
228
- ].join("\n"),
258
+ text,
259
+ html: renderBrandedEmailHtml({
260
+ brand,
261
+ languageCode,
262
+ heading: `${copy.subject} #${input.orderCode}`,
263
+ intro: `${copy.greeting} #${input.orderCode}.`,
264
+ lines: input.lines,
265
+ currencyCode: input.currencyCode,
266
+ details: [
267
+ [copy.shipping, input.shippingMethod || copy.notAvailable],
268
+ [copy.payment, input.paymentMethod || copy.notAvailable],
269
+ [copy.total, money(input.totalWithTax)],
270
+ ],
271
+ action: { label: copy.confirmation, url: input.confirmationUrl },
272
+ }),
273
+ };
274
+ }
275
+
276
+ export function renderOrderLifecycleEmail(
277
+ input: OrderLifecycleEmailInput,
278
+ ): OrderConfirmationEmail {
279
+ const { snapshot } = input;
280
+ const languageCode = toOrderEmailLanguageCode(snapshot.languageCode);
281
+ const copy = lifecycleEmailCopy[languageCode][input.kind];
282
+ const money = createMoneyFormatter(languageCode, snapshot.currencyCode);
283
+ const methodConfiguration =
284
+ input.kind === "order-cancelled"
285
+ ? undefined
286
+ : resolveShippingEmailConfiguration(
287
+ input.brandConfiguration,
288
+ input.snapshot.shippingMethodCode,
289
+ );
290
+ const trackingUrl =
291
+ input.kind === "shipment-shipped"
292
+ ? createTrackingUrl(
293
+ methodConfiguration?.trackingUrlTemplate ?? "",
294
+ input.snapshot.trackingCode,
295
+ )
296
+ : undefined;
297
+ const extraText =
298
+ input.kind === "pickup-ready"
299
+ ? methodConfiguration?.pickupDetails.trim()
300
+ : undefined;
301
+ const lines = snapshot.lines.map(
302
+ (line) => `- ${line.quantity}× ${line.name}: ${money(line.totalWithTax)}`,
303
+ );
304
+ const text = [
305
+ `${copy.intro} #${snapshot.orderCode}.`,
306
+ "",
307
+ copy.items,
308
+ ...lines,
309
+ "",
310
+ `${copy.total}: ${money(snapshot.totalWithTax)}`,
311
+ ...(extraText ? ["", `${copy.details}: ${extraText}`] : []),
312
+ ...(trackingUrl ? ["", `${copy.tracking}: ${trackingUrl}`] : []),
313
+ "",
314
+ ...renderTextBrandFooter(input.brandConfiguration),
315
+ ].join("\n");
316
+ return {
317
+ recipient: snapshot.recipient,
318
+ subject: `${copy.subject} #${snapshot.orderCode}`,
319
+ text,
320
+ html: renderBrandedEmailHtml({
321
+ brand: input.brandConfiguration,
322
+ languageCode,
323
+ heading: `${copy.subject} #${snapshot.orderCode}`,
324
+ intro: `${copy.intro} #${snapshot.orderCode}.`,
325
+ lines: snapshot.lines,
326
+ currencyCode: snapshot.currencyCode,
327
+ details: [
328
+ [copy.total, money(snapshot.totalWithTax)],
329
+ ...(extraText
330
+ ? ([[copy.details, extraText]] as [string, string][])
331
+ : []),
332
+ ],
333
+ ...(trackingUrl
334
+ ? { action: { label: copy.tracking, url: trackingUrl } }
335
+ : {}),
336
+ }),
229
337
  };
230
338
  }
231
339
 
@@ -264,7 +372,10 @@ export function sanitizeFileTransportEmail(
264
372
  "To: [redacted]",
265
373
  `Subject: ${email.subject}`,
266
374
  "",
267
- email.text.replace(/([?&]token=)[^\s]+/gu, "$1[redacted]"),
375
+ redactEmailTokens(email.text),
376
+ "",
377
+ "HTML:",
378
+ redactEmailTokens(email.html),
268
379
  ].join("\n");
269
380
  }
270
381
 
@@ -335,3 +446,200 @@ const emailCopy = {
335
446
  notAvailable: "not available",
336
447
  },
337
448
  } as const;
449
+
450
+ const lifecycleEmailCopy = {
451
+ sk: {
452
+ "pickup-ready": {
453
+ subject: "Objednávka je pripravená na vyzdvihnutie",
454
+ intro: "Vaša objednávka je pripravená na vyzdvihnutie",
455
+ items: "Pripravené položky:",
456
+ total: "Aktuálna suma objednávky",
457
+ details: "Miesto a pokyny na vyzdvihnutie",
458
+ tracking: "Sledovanie zásielky",
459
+ },
460
+ "pickup-picked-up": {
461
+ subject: "Objednávka bola vyzdvihnutá",
462
+ intro: "Vaša objednávka bola vyzdvihnutá",
463
+ items: "Vyzdvihnuté položky:",
464
+ total: "Celkom",
465
+ details: "Podrobnosti",
466
+ tracking: "Sledovanie zásielky",
467
+ },
468
+ "shipment-shipped": {
469
+ subject: "Objednávka bola odoslaná",
470
+ intro: "Časť vašej objednávky bola odoslaná",
471
+ items: "Odoslané položky:",
472
+ total: "Aktuálna suma objednávky",
473
+ details: "Podrobnosti",
474
+ tracking: "Sledovať zásielku",
475
+ },
476
+ "shipment-delivered": {
477
+ subject: "Objednávka bola doručená",
478
+ intro: "Časť vašej objednávky bola doručená",
479
+ items: "Doručené položky:",
480
+ total: "Aktuálna suma objednávky",
481
+ details: "Podrobnosti",
482
+ tracking: "Sledovanie zásielky",
483
+ },
484
+ "order-cancelled": {
485
+ subject: "Objednávka bola zrušená",
486
+ intro: "V objednávke bola vykonaná storno operácia",
487
+ items: "Zrušené položky:",
488
+ total: "Nová suma objednávky",
489
+ details: "Podrobnosti",
490
+ tracking: "Sledovanie zásielky",
491
+ },
492
+ },
493
+ en: {
494
+ "pickup-ready": {
495
+ subject: "Order ready for pickup",
496
+ intro: "Your order is ready for pickup",
497
+ items: "Items ready for pickup:",
498
+ total: "Current order total",
499
+ details: "Pickup location and instructions",
500
+ tracking: "Track shipment",
501
+ },
502
+ "pickup-picked-up": {
503
+ subject: "Order picked up",
504
+ intro: "Your order has been picked up",
505
+ items: "Picked-up items:",
506
+ total: "Total",
507
+ details: "Details",
508
+ tracking: "Track shipment",
509
+ },
510
+ "shipment-shipped": {
511
+ subject: "Order shipped",
512
+ intro: "Part of your order has been shipped",
513
+ items: "Shipped items:",
514
+ total: "Current order total",
515
+ details: "Details",
516
+ tracking: "Track shipment",
517
+ },
518
+ "shipment-delivered": {
519
+ subject: "Order delivered",
520
+ intro: "Part of your order has been delivered",
521
+ items: "Delivered items:",
522
+ total: "Current order total",
523
+ details: "Details",
524
+ tracking: "Track shipment",
525
+ },
526
+ "order-cancelled": {
527
+ subject: "Order cancelled",
528
+ intro: "A cancellation was applied to your order",
529
+ items: "Cancelled items:",
530
+ total: "New order total",
531
+ details: "Details",
532
+ tracking: "Track shipment",
533
+ },
534
+ },
535
+ } as const;
536
+
537
+ const defaultBrandConfiguration = {
538
+ brandName: "JarShop",
539
+ logoUrl: null,
540
+ primaryColor: "#111827",
541
+ websiteUrl: "https://jarwizz.com",
542
+ supportEmail: "support@jarwizz.com",
543
+ companyName: "JarShop",
544
+ companyAddress: "",
545
+ } as const;
546
+
547
+ function createMoneyFormatter(
548
+ languageCode: OrderEmailLanguageCode,
549
+ currencyCode: string,
550
+ ) {
551
+ const formatter = new Intl.NumberFormat(
552
+ languageCode === "sk" ? "sk-SK" : "en-GB",
553
+ { style: "currency", currency: currencyCode },
554
+ );
555
+ return (minorUnits: number) => formatter.format(minorUnits / 100);
556
+ }
557
+
558
+ function renderBrandedEmailHtml(input: {
559
+ brand: Pick<
560
+ JarShopEmailBrandConfiguration,
561
+ | "brandName"
562
+ | "logoUrl"
563
+ | "primaryColor"
564
+ | "websiteUrl"
565
+ | "supportEmail"
566
+ | "companyName"
567
+ | "companyAddress"
568
+ >;
569
+ languageCode: OrderEmailLanguageCode;
570
+ heading: string;
571
+ intro: string;
572
+ lines: ReadonlyArray<{
573
+ name: string;
574
+ quantity: number;
575
+ totalWithTax: number;
576
+ }>;
577
+ currencyCode: string;
578
+ details: readonly (readonly [string, string])[];
579
+ action?: { label: string; url: string };
580
+ }): string {
581
+ const money = new Intl.NumberFormat(
582
+ input.languageCode === "sk" ? "sk-SK" : "en-GB",
583
+ {
584
+ style: "currency",
585
+ currency: input.currencyCode,
586
+ },
587
+ );
588
+ const logo = input.brand.logoUrl
589
+ ? `<img src="${escapeHtml(input.brand.logoUrl)}" alt="${escapeHtml(input.brand.brandName)}" style="max-height:56px;max-width:180px">`
590
+ : `<strong style="font-size:24px">${escapeHtml(input.brand.brandName)}</strong>`;
591
+ const itemRows = input.lines
592
+ .map(
593
+ (line) =>
594
+ `<tr><td style="padding:8px;border-bottom:1px solid #e5e7eb">${escapeHtml(`${line.quantity}× ${line.name}`)}</td><td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right">${escapeHtml(money.format(line.totalWithTax / 100))}</td></tr>`,
595
+ )
596
+ .join("");
597
+ const details = input.details
598
+ .map(
599
+ ([label, value]) =>
600
+ `<p><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`,
601
+ )
602
+ .join("");
603
+ const action = input.action
604
+ ? `<p style="margin:24px 0"><a href="${escapeHtml(input.action.url)}" style="background:${escapeHtml(input.brand.primaryColor)};color:#fff;padding:12px 18px;text-decoration:none;border-radius:4px">${escapeHtml(input.action.label)}</a></p>`
605
+ : "";
606
+ return `<!doctype html><html><body style="margin:0;background:#f3f4f6;font-family:Arial,sans-serif;color:#111827"><main style="max-width:640px;margin:0 auto;background:#fff;padding:32px"><header><a href="${escapeHtml(input.brand.websiteUrl)}" style="color:${escapeHtml(input.brand.primaryColor)}">${logo}</a></header><h1 style="color:${escapeHtml(input.brand.primaryColor)}">${escapeHtml(input.heading)}</h1><p>${escapeHtml(input.intro)}</p><table style="width:100%;border-collapse:collapse">${itemRows}</table>${details}${action}<footer style="margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;font-size:12px;color:#6b7280"><p>${escapeHtml(input.brand.companyName)}<br>${escapeHtml(input.brand.companyAddress)}</p><p><a href="mailto:${escapeHtml(input.brand.supportEmail)}">${escapeHtml(input.brand.supportEmail)}</a></p></footer></main></body></html>`;
607
+ }
608
+
609
+ function renderTextBrandFooter(
610
+ brand: Pick<
611
+ JarShopEmailBrandConfiguration,
612
+ | "brandName"
613
+ | "websiteUrl"
614
+ | "supportEmail"
615
+ | "companyName"
616
+ | "companyAddress"
617
+ >,
618
+ ): string[] {
619
+ return [
620
+ brand.brandName,
621
+ brand.companyName,
622
+ brand.companyAddress,
623
+ brand.websiteUrl,
624
+ brand.supportEmail,
625
+ ].filter((value, index, values) => value && values.indexOf(value) === index);
626
+ }
627
+
628
+ function escapeHtml(value: string): string {
629
+ return value.replace(/[&<>"']/gu, (character) => htmlEscapes[character]);
630
+ }
631
+
632
+ function redactEmailTokens(value: string): string {
633
+ return value.replace(
634
+ /([?&](?:token|credential)=)[^\s"'&<]+/gu,
635
+ "$1[redacted]",
636
+ );
637
+ }
638
+
639
+ const htmlEscapes: Record<string, string> = {
640
+ "&": "&amp;",
641
+ "<": "&lt;",
642
+ ">": "&gt;",
643
+ '"': "&quot;",
644
+ "'": "&#39;",
645
+ };
@@ -1,7 +1,9 @@
1
+ import type { OrderEmailDeliveryKind } from "./order-email-delivery.entity.js";
2
+
1
3
  export type OrderEmailDeliveryStatusRecord = {
2
4
  id: number;
3
5
  orderId: number;
4
- kind: "order-confirmation";
6
+ kind: OrderEmailDeliveryKind;
5
7
  status: "pending" | "processing" | "sent" | "failed";
6
8
  attemptCount: number;
7
9
  nextAttemptAt: Date;
@@ -11,8 +11,16 @@ import {
11
11
  export type OrderEmailDeliveryStatus =
12
12
  "pending" | "processing" | "sent" | "failed";
13
13
 
14
+ export type OrderEmailDeliveryKind =
15
+ | "order-confirmation"
16
+ | "pickup-ready"
17
+ | "pickup-picked-up"
18
+ | "shipment-shipped"
19
+ | "shipment-delivered"
20
+ | "order-cancelled";
21
+
14
22
  @Entity({ name: "jarshop_order_email_delivery" })
15
- @Unique("UQ_jarshop_order_email_delivery_order_kind", ["orderId", "kind"])
23
+ @Unique("UQ_jarshop_order_email_delivery_event_key", ["eventKey"])
16
24
  @Index("IDX_jarshop_order_email_delivery_pending", ["status", "nextAttemptAt"])
17
25
  export class JarShopOrderEmailDelivery {
18
26
  @PrimaryGeneratedColumn()
@@ -21,11 +29,14 @@ export class JarShopOrderEmailDelivery {
21
29
  @Column({ type: "int" })
22
30
  orderId!: number;
23
31
 
24
- @Column({ type: "varchar", length: 36 })
25
- checkoutAttemptId!: string;
32
+ @Column({ type: "varchar", length: 255 })
33
+ eventKey!: string;
34
+
35
+ @Column({ type: "varchar", length: 36, nullable: true })
36
+ checkoutAttemptId!: string | null;
26
37
 
27
38
  @Column({ type: "varchar", length: 64 })
28
- kind!: "order-confirmation";
39
+ kind!: OrderEmailDeliveryKind;
29
40
 
30
41
  @Column({ type: "varchar", length: 10 })
31
42
  languageCode!: string;