@cowliss/cli 0.10.0 → 0.12.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/{examples → blueprints}/abandoned-checkout/journeys/abandoned-checkout.ts +1 -1
- package/{examples → blueprints}/activity-decay/journeys/activity-decay.ts +1 -1
- package/blueprints/ecommerce/emails/_shell.tsx +133 -0
- package/blueprints/ecommerce/emails/abandoned-checkout-last-call.tsx +46 -0
- package/blueprints/ecommerce/emails/abandoned-checkout.tsx +46 -0
- package/blueprints/ecommerce/emails/order-thanks.tsx +44 -0
- package/blueprints/ecommerce/emails/replenishment.tsx +40 -0
- package/blueprints/ecommerce/emails/review-request.tsx +44 -0
- package/blueprints/ecommerce/emails/vip-thanks.tsx +51 -0
- package/blueprints/ecommerce/emails/welcome.tsx +48 -0
- package/blueprints/ecommerce/emails/winback-offer.tsx +41 -0
- package/blueprints/ecommerce/emails/winback-reminder.tsx +45 -0
- package/blueprints/ecommerce/journeys/_helpers.ts +14 -0
- package/blueprints/ecommerce/journeys/abandoned-checkout.ts +44 -0
- package/blueprints/ecommerce/journeys/first-order-thanks.ts +40 -0
- package/blueprints/ecommerce/journeys/replenishment.ts +39 -0
- package/blueprints/ecommerce/journeys/vip.ts +29 -0
- package/blueprints/ecommerce/journeys/welcome.ts +42 -0
- package/blueprints/ecommerce/journeys/winback.ts +35 -0
- package/blueprints/ecommerce/scenarios/abandoned-checkout.last-call.json +30 -0
- package/blueprints/ecommerce/scenarios/abandoned-checkout.recovered.json +24 -0
- package/blueprints/ecommerce/scenarios/abandoned-checkout.reminded.json +33 -0
- package/blueprints/ecommerce/scenarios/first-order-thanks.delivered.json +35 -0
- package/blueprints/ecommerce/scenarios/first-order-thanks.never-delivered.json +21 -0
- package/blueprints/ecommerce/scenarios/replenishment.due.json +21 -0
- package/blueprints/ecommerce/scenarios/replenishment.reordered.json +20 -0
- package/blueprints/ecommerce/scenarios/vip.crossed.json +22 -0
- package/blueprints/ecommerce/scenarios/welcome.new-shopper.json +21 -0
- package/blueprints/ecommerce/scenarios/winback.returned.json +26 -0
- package/blueprints/ecommerce/scenarios/winback.still-gone.json +30 -0
- package/blueprints/ecommerce/segments/first-time-buyers.ts +24 -0
- package/blueprints/ecommerce/segments/lapsed.ts +27 -0
- package/blueprints/ecommerce/segments/repeat-buyers.ts +18 -0
- package/blueprints/ecommerce/segments/vip-customers.ts +20 -0
- package/blueprints/mobile-game/emails/_shell.tsx +138 -0
- package/blueprints/mobile-game/emails/day-two-return.tsx +41 -0
- package/blueprints/mobile-game/emails/lapsed-offer.tsx +41 -0
- package/blueprints/mobile-game/emails/lapsed-reminder.tsx +48 -0
- package/blueprints/mobile-game/emails/purchase-receipt.tsx +47 -0
- package/blueprints/mobile-game/emails/spender-thanks.tsx +53 -0
- package/blueprints/mobile-game/emails/stuck-hint.tsx +48 -0
- package/blueprints/mobile-game/emails/tutorial-nudge.tsx +48 -0
- package/blueprints/mobile-game/journeys/_helpers.ts +14 -0
- package/blueprints/mobile-game/journeys/day-two-return.ts +40 -0
- package/blueprints/mobile-game/journeys/first-purchase.ts +34 -0
- package/blueprints/mobile-game/journeys/lapsed-player.ts +39 -0
- package/blueprints/mobile-game/journeys/onboarding.ts +35 -0
- package/blueprints/mobile-game/journeys/spender-care.ts +41 -0
- package/blueprints/mobile-game/journeys/stuck.ts +58 -0
- package/blueprints/mobile-game/scenarios/day-two-return.came-back.json +16 -0
- package/blueprints/mobile-game/scenarios/day-two-return.no-show.json +21 -0
- package/blueprints/mobile-game/scenarios/first-purchase.paid.json +21 -0
- package/blueprints/mobile-game/scenarios/lapsed-player.returned.json +26 -0
- package/blueprints/mobile-game/scenarios/lapsed-player.still-gone.json +30 -0
- package/blueprints/mobile-game/scenarios/onboarding.finished.json +16 -0
- package/blueprints/mobile-game/scenarios/onboarding.stalled.json +21 -0
- package/blueprints/mobile-game/scenarios/spender-care.crossed.json +22 -0
- package/blueprints/mobile-game/scenarios/stuck.cleared.json +16 -0
- package/blueprints/mobile-game/scenarios/stuck.three-fails.json +30 -0
- package/blueprints/mobile-game/segments/active-players.ts +30 -0
- package/blueprints/mobile-game/segments/lapsed-players.ts +29 -0
- package/blueprints/mobile-game/segments/new-players.ts +31 -0
- package/blueprints/mobile-game/segments/paying-players.ts +21 -0
- package/blueprints/saas-trial/emails/_shell.tsx +151 -0
- package/blueprints/saas-trial/emails/activated.tsx +61 -0
- package/blueprints/saas-trial/emails/activation-help.tsx +49 -0
- package/blueprints/saas-trial/emails/activation-nudge.tsx +48 -0
- package/blueprints/saas-trial/emails/invite-your-team.tsx +58 -0
- package/blueprints/saas-trial/emails/trial-converted.tsx +57 -0
- package/blueprints/saas-trial/emails/trial-ended.tsx +52 -0
- package/blueprints/saas-trial/emails/trial-ending-activated.tsx +53 -0
- package/blueprints/saas-trial/emails/trial-ending-unactivated.tsx +54 -0
- package/blueprints/saas-trial/emails/trial-welcome.tsx +57 -0
- package/blueprints/saas-trial/journeys/_helpers.ts +14 -0
- package/blueprints/saas-trial/journeys/activated.ts +42 -0
- package/blueprints/saas-trial/journeys/activation-nudge.ts +44 -0
- package/blueprints/saas-trial/journeys/invite-your-team.ts +42 -0
- package/blueprints/saas-trial/journeys/trial-ended.ts +39 -0
- package/blueprints/saas-trial/journeys/trial-ending.ts +58 -0
- package/blueprints/saas-trial/journeys/trial-welcome.ts +35 -0
- package/blueprints/saas-trial/scenarios/activated.first-project.json +14 -0
- package/blueprints/saas-trial/scenarios/activated.second-project.json +12 -0
- package/blueprints/saas-trial/scenarios/activation-nudge.activated-late.json +16 -0
- package/blueprints/saas-trial/scenarios/activation-nudge.activated.json +8 -0
- package/blueprints/saas-trial/scenarios/activation-nudge.stalled.json +23 -0
- package/blueprints/saas-trial/scenarios/invite-your-team.alone.json +16 -0
- package/blueprints/saas-trial/scenarios/invite-your-team.invited.json +8 -0
- package/blueprints/saas-trial/scenarios/trial-ended.converted.json +16 -0
- package/blueprints/saas-trial/scenarios/trial-ended.expired.json +13 -0
- package/blueprints/saas-trial/scenarios/trial-ending.activated.json +20 -0
- package/blueprints/saas-trial/scenarios/trial-ending.converted.json +12 -0
- package/blueprints/saas-trial/scenarios/trial-ending.unactivated.json +16 -0
- package/blueprints/saas-trial/scenarios/trial-welcome.started.json +13 -0
- package/blueprints/saas-trial/segments/activated-users.ts +25 -0
- package/blueprints/saas-trial/segments/converted.ts +28 -0
- package/blueprints/saas-trial/segments/trialing.ts +26 -0
- package/blueprints/saas-trial/segments/unactivated.ts +27 -0
- package/{examples → blueprints}/winback/journeys/winback.ts +18 -7
- package/blueprints/winback/scenarios/winback.bought-again.json +14 -0
- package/{examples/winback/scenarios/winback.json → blueprints/winback/scenarios/winback.lapsed.json} +2 -5
- package/dist/guest/{driver-poSdZIj8.js → driver-B_pjmy5g.js} +2 -1
- package/dist/guest/driver-emails.js +3 -2
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/{index-EqBCZnpq.d.ts → index-DADBwPaG.d.ts} +63 -12
- package/dist/guest/{journeys-F8Yk8s1P.js → journeys-v2-Djgs8U91.js} +87 -71
- package/dist/guest/journeys.d.ts +1 -1
- package/dist/guest/journeys.js +58 -1
- package/dist/guest/segments.d.ts +81 -0
- package/dist/guest/segments.js +35 -0
- package/dist/index.js +1476 -638
- package/package.json +6 -2
- /package/{examples → blueprints}/abandoned-checkout/emails/abandoned-checkout.tsx +0 -0
- /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.recovered.json +0 -0
- /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +0 -0
- /package/{examples → blueprints}/activity-decay/scenarios/activity-decay.json +0 -0
- /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
|
-
*
|
|
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
|
-
*
|
|
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
|
+
});
|