@cowliss/cli 0.9.0 → 0.11.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/{examples → blueprints}/abandoned-checkout/journeys/abandoned-checkout.ts +1 -1
  2. package/{examples → blueprints}/activity-decay/journeys/activity-decay.ts +1 -1
  3. package/blueprints/ecommerce/emails/_shell.tsx +133 -0
  4. package/blueprints/ecommerce/emails/abandoned-checkout-last-call.tsx +46 -0
  5. package/blueprints/ecommerce/emails/abandoned-checkout.tsx +46 -0
  6. package/blueprints/ecommerce/emails/order-thanks.tsx +44 -0
  7. package/blueprints/ecommerce/emails/replenishment.tsx +40 -0
  8. package/blueprints/ecommerce/emails/review-request.tsx +44 -0
  9. package/blueprints/ecommerce/emails/vip-thanks.tsx +51 -0
  10. package/blueprints/ecommerce/emails/welcome.tsx +48 -0
  11. package/blueprints/ecommerce/emails/winback-offer.tsx +41 -0
  12. package/blueprints/ecommerce/emails/winback-reminder.tsx +45 -0
  13. package/blueprints/ecommerce/journeys/_helpers.ts +14 -0
  14. package/blueprints/ecommerce/journeys/abandoned-checkout.ts +44 -0
  15. package/blueprints/ecommerce/journeys/first-order-thanks.ts +40 -0
  16. package/blueprints/ecommerce/journeys/replenishment.ts +39 -0
  17. package/blueprints/ecommerce/journeys/vip.ts +29 -0
  18. package/blueprints/ecommerce/journeys/welcome.ts +42 -0
  19. package/blueprints/ecommerce/journeys/winback.ts +35 -0
  20. package/blueprints/ecommerce/scenarios/abandoned-checkout.last-call.json +30 -0
  21. package/blueprints/ecommerce/scenarios/abandoned-checkout.recovered.json +24 -0
  22. package/blueprints/ecommerce/scenarios/abandoned-checkout.reminded.json +33 -0
  23. package/blueprints/ecommerce/scenarios/first-order-thanks.delivered.json +35 -0
  24. package/blueprints/ecommerce/scenarios/first-order-thanks.never-delivered.json +21 -0
  25. package/blueprints/ecommerce/scenarios/replenishment.due.json +21 -0
  26. package/blueprints/ecommerce/scenarios/replenishment.reordered.json +20 -0
  27. package/blueprints/ecommerce/scenarios/vip.crossed.json +22 -0
  28. package/blueprints/ecommerce/scenarios/welcome.new-shopper.json +21 -0
  29. package/blueprints/ecommerce/scenarios/winback.returned.json +26 -0
  30. package/blueprints/ecommerce/scenarios/winback.still-gone.json +30 -0
  31. package/blueprints/ecommerce/segments/first-time-buyers.ts +24 -0
  32. package/blueprints/ecommerce/segments/lapsed.ts +27 -0
  33. package/blueprints/ecommerce/segments/repeat-buyers.ts +18 -0
  34. package/blueprints/ecommerce/segments/vip-customers.ts +20 -0
  35. package/blueprints/mobile-game/emails/_shell.tsx +138 -0
  36. package/blueprints/mobile-game/emails/day-two-return.tsx +41 -0
  37. package/blueprints/mobile-game/emails/lapsed-offer.tsx +41 -0
  38. package/blueprints/mobile-game/emails/lapsed-reminder.tsx +48 -0
  39. package/blueprints/mobile-game/emails/purchase-receipt.tsx +47 -0
  40. package/blueprints/mobile-game/emails/spender-thanks.tsx +53 -0
  41. package/blueprints/mobile-game/emails/stuck-hint.tsx +48 -0
  42. package/blueprints/mobile-game/emails/tutorial-nudge.tsx +48 -0
  43. package/blueprints/mobile-game/journeys/_helpers.ts +14 -0
  44. package/blueprints/mobile-game/journeys/day-two-return.ts +40 -0
  45. package/blueprints/mobile-game/journeys/first-purchase.ts +34 -0
  46. package/blueprints/mobile-game/journeys/lapsed-player.ts +39 -0
  47. package/blueprints/mobile-game/journeys/onboarding.ts +35 -0
  48. package/blueprints/mobile-game/journeys/spender-care.ts +41 -0
  49. package/blueprints/mobile-game/journeys/stuck.ts +58 -0
  50. package/blueprints/mobile-game/scenarios/day-two-return.came-back.json +16 -0
  51. package/blueprints/mobile-game/scenarios/day-two-return.no-show.json +21 -0
  52. package/blueprints/mobile-game/scenarios/first-purchase.paid.json +21 -0
  53. package/blueprints/mobile-game/scenarios/lapsed-player.returned.json +26 -0
  54. package/blueprints/mobile-game/scenarios/lapsed-player.still-gone.json +30 -0
  55. package/blueprints/mobile-game/scenarios/onboarding.finished.json +16 -0
  56. package/blueprints/mobile-game/scenarios/onboarding.stalled.json +21 -0
  57. package/blueprints/mobile-game/scenarios/spender-care.crossed.json +22 -0
  58. package/blueprints/mobile-game/scenarios/stuck.cleared.json +16 -0
  59. package/blueprints/mobile-game/scenarios/stuck.three-fails.json +30 -0
  60. package/blueprints/mobile-game/segments/active-players.ts +30 -0
  61. package/blueprints/mobile-game/segments/lapsed-players.ts +29 -0
  62. package/blueprints/mobile-game/segments/new-players.ts +31 -0
  63. package/blueprints/mobile-game/segments/paying-players.ts +21 -0
  64. package/blueprints/saas-trial/emails/_shell.tsx +151 -0
  65. package/blueprints/saas-trial/emails/activated.tsx +61 -0
  66. package/blueprints/saas-trial/emails/activation-help.tsx +49 -0
  67. package/blueprints/saas-trial/emails/activation-nudge.tsx +48 -0
  68. package/blueprints/saas-trial/emails/invite-your-team.tsx +58 -0
  69. package/blueprints/saas-trial/emails/trial-converted.tsx +57 -0
  70. package/blueprints/saas-trial/emails/trial-ended.tsx +52 -0
  71. package/blueprints/saas-trial/emails/trial-ending-activated.tsx +53 -0
  72. package/blueprints/saas-trial/emails/trial-ending-unactivated.tsx +54 -0
  73. package/blueprints/saas-trial/emails/trial-welcome.tsx +57 -0
  74. package/blueprints/saas-trial/journeys/_helpers.ts +14 -0
  75. package/blueprints/saas-trial/journeys/activated.ts +42 -0
  76. package/blueprints/saas-trial/journeys/activation-nudge.ts +44 -0
  77. package/blueprints/saas-trial/journeys/invite-your-team.ts +42 -0
  78. package/blueprints/saas-trial/journeys/trial-ended.ts +39 -0
  79. package/blueprints/saas-trial/journeys/trial-ending.ts +58 -0
  80. package/blueprints/saas-trial/journeys/trial-welcome.ts +35 -0
  81. package/blueprints/saas-trial/scenarios/activated.first-project.json +14 -0
  82. package/blueprints/saas-trial/scenarios/activated.second-project.json +12 -0
  83. package/blueprints/saas-trial/scenarios/activation-nudge.activated-late.json +16 -0
  84. package/blueprints/saas-trial/scenarios/activation-nudge.activated.json +8 -0
  85. package/blueprints/saas-trial/scenarios/activation-nudge.stalled.json +23 -0
  86. package/blueprints/saas-trial/scenarios/invite-your-team.alone.json +16 -0
  87. package/blueprints/saas-trial/scenarios/invite-your-team.invited.json +8 -0
  88. package/blueprints/saas-trial/scenarios/trial-ended.converted.json +16 -0
  89. package/blueprints/saas-trial/scenarios/trial-ended.expired.json +13 -0
  90. package/blueprints/saas-trial/scenarios/trial-ending.activated.json +20 -0
  91. package/blueprints/saas-trial/scenarios/trial-ending.converted.json +12 -0
  92. package/blueprints/saas-trial/scenarios/trial-ending.unactivated.json +16 -0
  93. package/blueprints/saas-trial/scenarios/trial-welcome.started.json +13 -0
  94. package/blueprints/saas-trial/segments/activated-users.ts +25 -0
  95. package/blueprints/saas-trial/segments/converted.ts +28 -0
  96. package/blueprints/saas-trial/segments/trialing.ts +26 -0
  97. package/blueprints/saas-trial/segments/unactivated.ts +27 -0
  98. package/{examples → blueprints}/winback/journeys/winback.ts +18 -7
  99. package/blueprints/winback/scenarios/winback.bought-again.json +14 -0
  100. package/{examples/winback/scenarios/winback.json → blueprints/winback/scenarios/winback.lapsed.json} +2 -5
  101. package/dist/guest/{driver-poSdZIj8.js → driver-B_pjmy5g.js} +2 -1
  102. package/dist/guest/driver-emails.js +3 -2
  103. package/dist/guest/driver.d.ts +1 -1
  104. package/dist/guest/driver.js +1 -1
  105. package/dist/guest/{index-EqBCZnpq.d.ts → index-DADBwPaG.d.ts} +63 -12
  106. package/dist/guest/{journeys-F8Yk8s1P.js → journeys-v2-Djgs8U91.js} +87 -71
  107. package/dist/guest/journeys.d.ts +1 -1
  108. package/dist/guest/journeys.js +58 -1
  109. package/dist/guest/segments.d.ts +81 -0
  110. package/dist/guest/segments.js +35 -0
  111. package/dist/index.js +1191 -398
  112. package/package.json +6 -2
  113. /package/{examples → blueprints}/abandoned-checkout/emails/abandoned-checkout.tsx +0 -0
  114. /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.recovered.json +0 -0
  115. /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +0 -0
  116. /package/{examples → blueprints}/activity-decay/scenarios/activity-decay.json +0 -0
  117. /package/{examples → blueprints}/winback/emails/winback.tsx +0 -0
@@ -11,7 +11,7 @@ import { defineJourney } from "@cowliss/cli/journeys";
11
11
  * `enrollment: { cooldown: "30d" }` to reach a repeat abandoner again, a
12
12
  * month after their last reminder.
13
13
  *
14
- * Example: add it with `cow add example abandoned-checkout`; rename the
14
+ * Blueprint: add it with `cow add blueprint abandoned-checkout`; rename the
15
15
  * events and the email to your domain's vocabulary.
16
16
  */
17
17
  export default defineJourney({
@@ -22,7 +22,7 @@ import { defineJourney } from "@cowliss/cli/journeys";
22
22
  * trait write below from flipping a segment whose entry event restarts this
23
23
  * journey, forever.
24
24
  *
25
- * Example: add it with `cow add example activity-decay`; rename the trait to
25
+ * Blueprint: add it with `cow add blueprint activity-decay`; rename the trait to
26
26
  * your domain's vocabulary, or narrow the pattern (`checkout.*`, or a list)
27
27
  * if only some events should count as activity.
28
28
  */
@@ -0,0 +1,133 @@
1
+ import {
2
+ Body,
3
+ Button,
4
+ Container,
5
+ Head,
6
+ Heading,
7
+ Hr,
8
+ Html,
9
+ Preview,
10
+ Text,
11
+ } from "@react-email/components";
12
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
13
+
14
+ /**
15
+ * The chrome all nine mails in this blueprint share: one look for the
16
+ * programme rather than nine copies of the same inline styles. An
17
+ * `_`-prefixed file under `emails/` is a shared module, not a template:
18
+ * `cow build` skips it in discovery and bundles it into whichever template
19
+ * imports it.
20
+ *
21
+ * Inline styles only, no Tailwind and no stylesheet, because email clients
22
+ * strip both.
23
+ *
24
+ * The three constants below are the first thing to change. The postal
25
+ * address is not decoration: a marketing mail is required to carry one in
26
+ * most of the world, and Cowliss does not know yours. The unsubscribe link
27
+ * is the one thing you do not add here — the send path appends it to every
28
+ * marketing mail, and one that is already in the body is left alone.
29
+ */
30
+
31
+ export const STORE_NAME = "Acme";
32
+ export const STORE_URL = "https://example.com";
33
+ export const STORE_ADDRESS = "Acme, 1 Example Street, Springfield";
34
+
35
+ export const bodyStyle: CSSProperties = {
36
+ backgroundColor: "#f4f4f5",
37
+ fontFamily:
38
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
39
+ margin: "0",
40
+ padding: "32px 0",
41
+ };
42
+
43
+ export const containerStyle: CSSProperties = {
44
+ margin: "0 auto",
45
+ padding: "40px 36px",
46
+ maxWidth: "560px",
47
+ backgroundColor: "#ffffff",
48
+ borderRadius: "14px",
49
+ border: "1px solid #e4e4e7",
50
+ };
51
+
52
+ export const headingStyle: CSSProperties = {
53
+ fontSize: "24px",
54
+ lineHeight: "1.25",
55
+ fontWeight: 700,
56
+ letterSpacing: "-0.02em",
57
+ color: "#09090b",
58
+ margin: "0 0 18px",
59
+ };
60
+
61
+ export const textStyle: CSSProperties = {
62
+ fontSize: "16px",
63
+ lineHeight: "1.7",
64
+ color: "#27272a",
65
+ margin: "0 0 16px",
66
+ };
67
+
68
+ export const buttonStyle: CSSProperties = {
69
+ display: "inline-block",
70
+ marginTop: "8px",
71
+ marginBottom: "16px",
72
+ padding: "14px 26px",
73
+ borderRadius: "10px",
74
+ backgroundColor: "#09090b",
75
+ color: "#ffffff",
76
+ fontSize: "15px",
77
+ fontWeight: 600,
78
+ lineHeight: 1,
79
+ textDecoration: "none",
80
+ };
81
+
82
+ const hrStyle: CSSProperties = {
83
+ borderColor: "#e4e4e7",
84
+ borderStyle: "solid",
85
+ borderWidth: "1px 0 0 0",
86
+ margin: "24px 0",
87
+ };
88
+
89
+ const footerStyle: CSSProperties = {
90
+ fontSize: "13px",
91
+ lineHeight: "1.6",
92
+ color: "#52525b",
93
+ margin: "0",
94
+ };
95
+
96
+ export function StoreEmail(props: {
97
+ preview: string;
98
+ heading: string;
99
+ children: ReactNode;
100
+ }): ReactElement {
101
+ return (
102
+ <Html dir="ltr" lang="en">
103
+ <Head>
104
+ <meta content="text/html; charset=UTF-8" httpEquiv="Content-Type" />
105
+ <meta name="x-apple-disable-message-reformatting" />
106
+ <meta name="color-scheme" content="light" />
107
+ <meta name="supported-color-schemes" content="light" />
108
+ </Head>
109
+ <Preview>{props.preview}</Preview>
110
+ <Body style={bodyStyle}>
111
+ <Container style={containerStyle}>
112
+ <Heading style={headingStyle}>{props.heading}</Heading>
113
+ {props.children}
114
+ <Hr style={hrStyle} />
115
+ <Text style={footerStyle}>{STORE_ADDRESS}</Text>
116
+ </Container>
117
+ </Body>
118
+ </Html>
119
+ );
120
+ }
121
+
122
+ /** The one action a mail asks for. Every mail here has exactly one. */
123
+ export function Cta(props: { href: string; children: string }): ReactElement {
124
+ return (
125
+ <Button style={buttonStyle} href={props.href}>
126
+ {props.children}
127
+ </Button>
128
+ );
129
+ }
130
+
131
+ /** "Hi Ada," when the journey passed a first name, "Hi," when it did not. */
132
+ export const greeting = (firstName?: string): string =>
133
+ firstName ? `Hi ${firstName},` : "Hi,";
@@ -0,0 +1,46 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { Cta, greeting, STORE_URL, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * The second and last cart reminder, a day after the first. It says this
9
+ * is the last one, and that is the only new thing it has to say. A third
10
+ * reminder is how a store gets marked as spam.
11
+ */
12
+
13
+ export const props = z.object({
14
+ checkoutUrl: z.url().optional(),
15
+ firstName: z.string().optional(),
16
+ });
17
+
18
+ export const subject: Subject<typeof props> = (p) =>
19
+ p.firstName
20
+ ? `${p.firstName}, last call on your cart`
21
+ : "Last call on your cart";
22
+
23
+ export const sendClass: SendClass = "marketing";
24
+
25
+ export const tags = ["shop", "checkout"];
26
+
27
+ export default function AbandonedCheckoutLastCall(
28
+ p: z.infer<typeof props>,
29
+ ): ReactElement {
30
+ return (
31
+ <StoreEmail
32
+ preview="The last one about this cart."
33
+ heading={greeting(p.firstName)}
34
+ >
35
+ <Text style={textStyle}>
36
+ This is the last reminder about your cart. Stock moves, so what is in it
37
+ may not be there much longer.
38
+ </Text>
39
+ <Text style={textStyle}>
40
+ If you have changed your mind, nothing else about this cart will reach
41
+ you.
42
+ </Text>
43
+ <Cta href={p.checkoutUrl ?? STORE_URL}>Finish checkout</Cta>
44
+ </StoreEmail>
45
+ );
46
+ }
@@ -0,0 +1,46 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { Cta, greeting, STORE_URL, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * The first cart reminder, 24 hours after a checkout that went nowhere.
9
+ * No discount: most abandoned carts are a distraction, not a price
10
+ * objection, and a store that pays people to leave gets more of it.
11
+ *
12
+ * `checkoutUrl` belongs to your store, so the journey reads it off the
13
+ * `checkout.started` event and the button falls back to the shop when the
14
+ * event did not carry one.
15
+ */
16
+
17
+ export const props = z.object({
18
+ checkoutUrl: z.url().optional(),
19
+ firstName: z.string().optional(),
20
+ });
21
+
22
+ export const subject: Subject<typeof props> = (p) =>
23
+ p.firstName
24
+ ? `${p.firstName}, your cart is still here`
25
+ : "Your cart is still here";
26
+
27
+ export const sendClass: SendClass = "marketing";
28
+
29
+ export const tags = ["shop", "checkout"];
30
+
31
+ export default function AbandonedCheckout(
32
+ p: z.infer<typeof props>,
33
+ ): ReactElement {
34
+ return (
35
+ <StoreEmail
36
+ preview="Your cart is still here."
37
+ heading={greeting(p.firstName)}
38
+ >
39
+ <Text style={textStyle}>
40
+ You started checking out and did not finish. Your cart is saved, so
41
+ picking it up again takes a moment.
42
+ </Text>
43
+ <Cta href={p.checkoutUrl ?? STORE_URL}>Finish checkout</Cta>
44
+ </StoreEmail>
45
+ );
46
+ }
@@ -0,0 +1,44 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { greeting, STORE_NAME, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * The thank-you for a first order. It is not a receipt: your shop already
9
+ * sent one, with the numbers on it. This is the note that says a person
10
+ * saw the order, so it carries no total, no tracking link and nothing to
11
+ * buy.
12
+ *
13
+ * Transactional, and it has no call to action at all. That is on purpose:
14
+ * a button here would make it a marketing mail wearing a thank-you.
15
+ */
16
+
17
+ export const props = z.object({
18
+ firstName: z.string().optional(),
19
+ });
20
+
21
+ export const subject: Subject<typeof props> = () =>
22
+ `Thanks for your first order`;
23
+
24
+ export const sendClass: SendClass = "transactional";
25
+
26
+ export const tags = ["shop", "orders"];
27
+
28
+ export default function OrderThanks(p: z.infer<typeof props>): ReactElement {
29
+ return (
30
+ <StoreEmail
31
+ preview="Your first order is on its way."
32
+ heading={greeting(p.firstName)}
33
+ >
34
+ <Text style={textStyle}>
35
+ Your first order from {STORE_NAME} is packed and on its way. Your
36
+ receipt and tracking are in a separate mail.
37
+ </Text>
38
+ <Text style={textStyle}>
39
+ If anything about it is wrong, reply to this message and a person reads
40
+ it.
41
+ </Text>
42
+ </StoreEmail>
43
+ );
44
+ }
@@ -0,0 +1,40 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { Cta, greeting, STORE_URL, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * The reorder nudge, sent when a regular customer's usual cycle has gone
9
+ * by without an order. Its whole argument is timing, so it makes no offer
10
+ * and asks for nothing except a reorder.
11
+ */
12
+
13
+ export const props = z.object({
14
+ firstName: z.string().optional(),
15
+ });
16
+
17
+ export const subject: Subject<typeof props> = () => "Running low?";
18
+
19
+ export const sendClass: SendClass = "marketing";
20
+
21
+ export const tags = ["shop", "retention"];
22
+
23
+ export default function Replenishment(p: z.infer<typeof props>): ReactElement {
24
+ return (
25
+ <StoreEmail
26
+ preview="Reorder in a couple of taps."
27
+ heading={greeting(p.firstName)}
28
+ >
29
+ <Text style={textStyle}>
30
+ It has been about six weeks since your last order, which is usually when
31
+ people come back for more.
32
+ </Text>
33
+ <Text style={textStyle}>
34
+ Your last order is saved, so reordering the same thing takes a couple of
35
+ taps.
36
+ </Text>
37
+ <Cta href={`${STORE_URL}/orders`}>Reorder</Cta>
38
+ </StoreEmail>
39
+ );
40
+ }
@@ -0,0 +1,44 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { Cta, greeting, STORE_URL, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * Asked three days after the order was delivered, so there is something to
9
+ * answer. One question, one link, and an honest way out for someone whose
10
+ * order went wrong.
11
+ *
12
+ * Transactional, and it stays that way only as long as it is about their
13
+ * order. Add a discount for leaving a review and it has become marketing:
14
+ * move it to a journey with a marketing purpose instead.
15
+ */
16
+
17
+ export const props = z.object({
18
+ firstName: z.string().optional(),
19
+ });
20
+
21
+ export const subject: Subject<typeof props> = () => "How is your order?";
22
+
23
+ export const sendClass: SendClass = "transactional";
24
+
25
+ export const tags = ["shop", "orders"];
26
+
27
+ export default function ReviewRequest(p: z.infer<typeof props>): ReactElement {
28
+ return (
29
+ <StoreEmail
30
+ preview="Two minutes, if you have them."
31
+ heading={greeting(p.firstName)}
32
+ >
33
+ <Text style={textStyle}>
34
+ Your order arrived a few days ago. If you have two minutes, tell us how
35
+ it is going. Other people read these before they buy.
36
+ </Text>
37
+ <Text style={textStyle}>
38
+ If it did not go well, reply to this message instead and we will sort it
39
+ out.
40
+ </Text>
41
+ <Cta href={`${STORE_URL}/reviews`}>Write a review</Cta>
42
+ </StoreEmail>
43
+ );
44
+ }
@@ -0,0 +1,51 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import {
6
+ Cta,
7
+ greeting,
8
+ STORE_NAME,
9
+ STORE_URL,
10
+ StoreEmail,
11
+ textStyle,
12
+ } from "./_shell";
13
+
14
+ /**
15
+ * Sent once a customer crosses the lifetime-value threshold. A thank-you
16
+ * and one perk, and the perk is free shipping because it is worth
17
+ * something every time they order and costs nothing to explain.
18
+ *
19
+ * No tier name and no list of benefits: this is a note about them, not an
20
+ * announcement about a programme.
21
+ */
22
+
23
+ export const props = z.object({
24
+ firstName: z.string().optional(),
25
+ });
26
+
27
+ export const subject: Subject<typeof props> = () =>
28
+ "Free shipping, from now on";
29
+
30
+ export const sendClass: SendClass = "marketing";
31
+
32
+ export const tags = ["shop", "value"];
33
+
34
+ export default function VipThanks(p: z.infer<typeof props>): ReactElement {
35
+ return (
36
+ <StoreEmail
37
+ preview="Free shipping on everything you order."
38
+ heading={greeting(p.firstName)}
39
+ >
40
+ <Text style={textStyle}>
41
+ You are one of the handful of people who keep {STORE_NAME} going, and we
42
+ noticed.
43
+ </Text>
44
+ <Text style={textStyle}>
45
+ From now on your orders ship free, with nothing to enter and no minimum.
46
+ It is already on your account.
47
+ </Text>
48
+ <Cta href={STORE_URL}>Browse the shop</Cta>
49
+ </StoreEmail>
50
+ );
51
+ }
@@ -0,0 +1,48 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import {
6
+ Cta,
7
+ greeting,
8
+ STORE_NAME,
9
+ STORE_URL,
10
+ StoreEmail,
11
+ textStyle,
12
+ } from "./_shell";
13
+
14
+ /**
15
+ * The welcome, sent to someone who has looked at a product and not
16
+ * ordered. It sells nothing in particular, because you do not yet know
17
+ * what they want: it says who you are and gets them back to the shop.
18
+ */
19
+
20
+ export const props = z.object({
21
+ firstName: z.string().optional(),
22
+ });
23
+
24
+ export const subject: Subject<typeof props> = (p) =>
25
+ p.firstName ? `Welcome, ${p.firstName}` : `Welcome to ${STORE_NAME}`;
26
+
27
+ export const sendClass: SendClass = "marketing";
28
+
29
+ export const tags = ["shop", "lifecycle"];
30
+
31
+ export default function Welcome(p: z.infer<typeof props>): ReactElement {
32
+ return (
33
+ <StoreEmail
34
+ preview="Everything we make, in one place."
35
+ heading={greeting(p.firstName)}
36
+ >
37
+ <Text style={textStyle}>
38
+ Thanks for stopping by {STORE_NAME}. Everything we sell is made in small
39
+ batches, and we only list what we would keep for ourselves.
40
+ </Text>
41
+ <Text style={textStyle}>
42
+ Orders placed before noon go out the same day, and anything that is not
43
+ right comes back to us free for 30 days.
44
+ </Text>
45
+ <Cta href={STORE_URL}>Browse the shop</Cta>
46
+ </StoreEmail>
47
+ );
48
+ }
@@ -0,0 +1,41 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import { Cta, greeting, STORE_URL, StoreEmail, textStyle } from "./_shell";
6
+
7
+ /**
8
+ * The second and last win-back, two weeks after the reminder went
9
+ * unanswered. This is where the discount goes, and the code is written out
10
+ * here because it is one code for one campaign: change it, and the 10%
11
+ * below, when you change the offer.
12
+ */
13
+
14
+ export const props = z.object({
15
+ firstName: z.string().optional(),
16
+ });
17
+
18
+ export const subject: Subject<typeof props> = () => "10% off, if you want it";
19
+
20
+ export const sendClass: SendClass = "marketing";
21
+
22
+ export const tags = ["shop", "retention"];
23
+
24
+ export default function WinbackOffer(p: z.infer<typeof props>): ReactElement {
25
+ return (
26
+ <StoreEmail
27
+ preview="COMEBACK10, for the next 30 days."
28
+ heading={greeting(p.firstName)}
29
+ >
30
+ <Text style={textStyle}>
31
+ We would rather have you back than be right about the price, so here is
32
+ 10% off your next order with the code COMEBACK10. It works for the next
33
+ 30 days.
34
+ </Text>
35
+ <Text style={textStyle}>
36
+ This is the last mail we will send about it either way.
37
+ </Text>
38
+ <Cta href={STORE_URL}>Use the code</Cta>
39
+ </StoreEmail>
40
+ );
41
+ }
@@ -0,0 +1,45 @@
1
+ import type { SendClass, Subject } from "@cowliss/cli/emails";
2
+ import { Text } from "@react-email/components";
3
+ import type { ReactElement } from "react";
4
+ import { z } from "zod";
5
+ import {
6
+ Cta,
7
+ greeting,
8
+ STORE_NAME,
9
+ STORE_URL,
10
+ StoreEmail,
11
+ textStyle,
12
+ } from "./_shell";
13
+
14
+ /**
15
+ * The first win-back, sent when a customer becomes lapsed. No discount
16
+ * and no urgency: they already know the shop, so the job is to remind
17
+ * them it is still here and worth a look.
18
+ */
19
+
20
+ export const props = z.object({
21
+ firstName: z.string().optional(),
22
+ });
23
+
24
+ export const subject: Subject<typeof props> = () => "It has been a while";
25
+
26
+ export const sendClass: SendClass = "marketing";
27
+
28
+ export const tags = ["shop", "retention"];
29
+
30
+ export default function WinbackReminder(
31
+ p: z.infer<typeof props>,
32
+ ): ReactElement {
33
+ return (
34
+ <StoreEmail
35
+ preview="New things since you were last here."
36
+ heading={greeting(p.firstName)}
37
+ >
38
+ <Text style={textStyle}>
39
+ It has been a few months since your last order. Plenty has changed at
40
+ {` ${STORE_NAME}`} since then, and your details are where you left them.
41
+ </Text>
42
+ <Cta href={STORE_URL}>See what is new</Cta>
43
+ </StoreEmail>
44
+ );
45
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one thing all six journeys share. An `_`-prefixed file under
3
+ * `journeys/` is a module, not a journey: `cow build` skips it in
4
+ * discovery and bundles it into whichever journey imports it.
5
+ *
6
+ * Deliberately nothing else lives here. Addresses, waits and thresholds
7
+ * stay written out in the journey that uses them, so changing when the
8
+ * win-back mail goes out is one edit in `winback.ts` and not a hunt
9
+ * through a shared config.
10
+ */
11
+
12
+ /** A trait, which is `unknown` on the way out of a profile, as a string. */
13
+ export const str = (value: unknown): string | undefined =>
14
+ typeof value === "string" ? value : undefined;
@@ -0,0 +1,44 @@
1
+ import { defineJourney } from "@cowliss/cli/journeys";
2
+ import { str } from "./_helpers";
3
+
4
+ /**
5
+ * Abandoned checkout, in two touches. `checkout.started` opens a 24-hour
6
+ * window; an `order.placed` inside it closes the journey quietly and marks
7
+ * the cart recovered, a trait your other journeys and segments can read.
8
+ * Silence spends one reminder, waits another 24 hours, and spends a last
9
+ * one.
10
+ *
11
+ * Two literals to argue with: the first `24h` is how long you leave someone
12
+ * alone before reminding them, the second is the gap before the last call.
13
+ * Shorten the first if your basket is cheap and impulse-bought.
14
+ *
15
+ * `marketing` with a 30-day cooldown, so a shopper who abandons a cart
16
+ * every week is reminded once a month rather than every week.
17
+ */
18
+ export default defineJourney({
19
+ trigger: { event: "checkout.started" },
20
+ purpose: "marketing",
21
+ enrollment: { cooldown: "30d" },
22
+ from: "Acme <checkout@example.com>",
23
+ description: "Reminds someone who left items in their cart, twice at most.",
24
+ tags: ["shop", "checkout"],
25
+ run: async (event, api) => {
26
+ if (await api.waitForEvent("order.placed", { timeout: "24h" })) {
27
+ await api.traits.set("cartRecovered", true);
28
+ return;
29
+ }
30
+ // The checkout link belongs to your store, so the trigger event carries
31
+ // it; the mail drops its button when the property is missing.
32
+ const me = await api.profile.get();
33
+ const props = {
34
+ checkoutUrl: str(event.properties.checkoutUrl),
35
+ firstName: str(me.traits.firstName),
36
+ };
37
+ await api.send.email({ template: "abandoned-checkout", props });
38
+ if (await api.waitForEvent("order.placed", { timeout: "24h" })) {
39
+ await api.traits.set("cartRecovered", true);
40
+ return;
41
+ }
42
+ await api.send.email({ template: "abandoned-checkout-last-call", props });
43
+ },
44
+ });
@@ -0,0 +1,40 @@
1
+ import { defineJourney } from "@cowliss/cli/journeys";
2
+ import { str } from "./_helpers";
3
+
4
+ /**
5
+ * The first order, thanked, and the review asked for once it arrives.
6
+ *
7
+ * It enrolls from `segments/first-time-buyers.ts` rather than from
8
+ * `order.placed`, so "their first order" is the audience's job and this
9
+ * file has no counting to do. Every later order leaves them out of that
10
+ * audience, which is what makes the thank-you the first one only.
11
+ *
12
+ * `transactional`, the only one of the six that is: both mails are about
13
+ * one order this person chose to place, which is why neither is gated on
14
+ * marketing consent and why the journey takes no cooldown. That is also
15
+ * the line to watch. Put a discount code in the review request and it
16
+ * stops being about their order: move it to a marketing journey of its
17
+ * own rather than sending a promotion under this purpose.
18
+ *
19
+ * The review request waits for delivery and then sleeps 3 days, because
20
+ * the honest answer to "how is it?" needs the thing in their hands for a
21
+ * few days. Nothing is sent if the delivery event never arrives.
22
+ */
23
+ export default defineJourney({
24
+ trigger: { segment: "first-time-buyers" },
25
+ purpose: "transactional",
26
+ from: "Acme <orders@example.com>",
27
+ description:
28
+ "Thanks a first-time customer, then asks how the order went once it arrives.",
29
+ tags: ["shop", "orders"],
30
+ run: async (_event, api) => {
31
+ const me = await api.profile.get();
32
+ const props = { firstName: str(me.traits.firstName) };
33
+ await api.send.email({ template: "order-thanks", props });
34
+ if (!(await api.waitForEvent("order.delivered", { timeout: "30d" }))) {
35
+ return;
36
+ }
37
+ await api.sleep("3d");
38
+ await api.send.email({ template: "review-request", props });
39
+ },
40
+ });