@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.
- 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 +1191 -398
- 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
|
@@ -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, GAME_LINK, GameEmail, greeting, textStyle } from "./_shell";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sent a day and a half after the tutorial, to a player who has not come
|
|
9
|
+
* back. It names one concrete thing waiting for them rather than asking
|
|
10
|
+
* them to come back in general: the second line is the one to rewrite for
|
|
11
|
+
* your own game, and it should name something that is true.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const props = z.object({
|
|
15
|
+
firstName: z.string().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const subject: Subject<typeof props> = () => "Your next run is ready";
|
|
19
|
+
|
|
20
|
+
export const sendClass: SendClass = "marketing";
|
|
21
|
+
|
|
22
|
+
export const tags = ["game", "retention"];
|
|
23
|
+
|
|
24
|
+
export default function DayTwoReturn(p: z.infer<typeof props>): ReactElement {
|
|
25
|
+
return (
|
|
26
|
+
<GameEmail
|
|
27
|
+
preview="Your camp is where you left it."
|
|
28
|
+
heading={greeting(p.firstName)}
|
|
29
|
+
>
|
|
30
|
+
<Text style={textStyle}>
|
|
31
|
+
You made it through the tutorial, which is the slowest part. What comes
|
|
32
|
+
next is the part people stay for.
|
|
33
|
+
</Text>
|
|
34
|
+
<Text style={textStyle}>
|
|
35
|
+
Your camp is where you left it, and the first daily reward is still
|
|
36
|
+
waiting to be collected.
|
|
37
|
+
</Text>
|
|
38
|
+
<Cta href={GAME_LINK}>Open the game</Cta>
|
|
39
|
+
</GameEmail>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -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, GAME_LINK, GameEmail, greeting, textStyle } from "./_shell";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The second and last return mail, a week after the reminder went
|
|
9
|
+
* unanswered. This is where the offer goes, and it is written out here
|
|
10
|
+
* because it is one offer for one campaign: change the week and the
|
|
11
|
+
* doubling together when you change it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const props = z.object({
|
|
15
|
+
firstName: z.string().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const subject: Subject<typeof props> = () =>
|
|
19
|
+
"A week of double coins, if you want it";
|
|
20
|
+
|
|
21
|
+
export const sendClass: SendClass = "marketing";
|
|
22
|
+
|
|
23
|
+
export const tags = ["game", "lifecycle"];
|
|
24
|
+
|
|
25
|
+
export default function LapsedOffer(p: z.infer<typeof props>): ReactElement {
|
|
26
|
+
return (
|
|
27
|
+
<GameEmail
|
|
28
|
+
preview="Double coins for seven days, from your next run."
|
|
29
|
+
heading={greeting(p.firstName)}
|
|
30
|
+
>
|
|
31
|
+
<Text style={textStyle}>
|
|
32
|
+
Every coin you earn is worth double for seven days, counting from the
|
|
33
|
+
next time you open the game. There is nothing to enter.
|
|
34
|
+
</Text>
|
|
35
|
+
<Text style={textStyle}>
|
|
36
|
+
This is the last mail we will send about it either way.
|
|
37
|
+
</Text>
|
|
38
|
+
<Cta href={GAME_LINK}>Claim double coins</Cta>
|
|
39
|
+
</GameEmail>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -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
|
+
GAME_LINK,
|
|
8
|
+
GAME_NAME,
|
|
9
|
+
GameEmail,
|
|
10
|
+
greeting,
|
|
11
|
+
textStyle,
|
|
12
|
+
} from "./_shell";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The first return mail, sent when a player has not opened the game in a
|
|
16
|
+
* month. No currency and no countdown: they already know the game, so the
|
|
17
|
+
* job is to say it is still here, their save survived, and something has
|
|
18
|
+
* changed since they left.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const props = z.object({
|
|
22
|
+
firstName: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const subject: Subject<typeof props> = () => "Your save is still here";
|
|
26
|
+
|
|
27
|
+
export const sendClass: SendClass = "marketing";
|
|
28
|
+
|
|
29
|
+
export const tags = ["game", "lifecycle"];
|
|
30
|
+
|
|
31
|
+
export default function LapsedReminder(p: z.infer<typeof props>): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<GameEmail
|
|
34
|
+
preview="Three new chapters since your last run."
|
|
35
|
+
heading={greeting(p.firstName)}
|
|
36
|
+
>
|
|
37
|
+
<Text style={textStyle}>
|
|
38
|
+
It has been about a month since you last played {GAME_NAME}. Your save
|
|
39
|
+
is untouched, with everything you had built on it.
|
|
40
|
+
</Text>
|
|
41
|
+
<Text style={textStyle}>
|
|
42
|
+
Three chapters have shipped since then, and the one you were on now ends
|
|
43
|
+
somewhere different.
|
|
44
|
+
</Text>
|
|
45
|
+
<Cta href={GAME_LINK}>Open the game</Cta>
|
|
46
|
+
</GameEmail>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { GAME_NAME, GameEmail, greeting, textStyle } from "./_shell";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one transactional mail in this blueprint. It is not the payment
|
|
9
|
+
* receipt — the app store sends that, with the money on it — it is the
|
|
10
|
+
* game confirming that what was paid for reached the account, which is the
|
|
11
|
+
* part the store cannot say and the part a player writes to support about.
|
|
12
|
+
*
|
|
13
|
+
* No call to action at all, on purpose: a button here would make it a
|
|
14
|
+
* marketing mail wearing a receipt, and a receipt is the one mail that
|
|
15
|
+
* reaches a player who has refused marketing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const props = z.object({
|
|
19
|
+
firstName: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const subject: Subject<typeof props> = () =>
|
|
23
|
+
"Your purchase is on your account";
|
|
24
|
+
|
|
25
|
+
export const sendClass: SendClass = "transactional";
|
|
26
|
+
|
|
27
|
+
export const tags = ["game", "revenue"];
|
|
28
|
+
|
|
29
|
+
export default function PurchaseReceipt(
|
|
30
|
+
p: z.infer<typeof props>,
|
|
31
|
+
): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<GameEmail
|
|
34
|
+
preview="Everything it includes is already there."
|
|
35
|
+
heading={greeting(p.firstName)}
|
|
36
|
+
>
|
|
37
|
+
<Text style={textStyle}>
|
|
38
|
+
Your first purchase in {GAME_NAME} went through, and everything it
|
|
39
|
+
includes is already on your account. The payment receipt comes from the
|
|
40
|
+
app store rather than from us.
|
|
41
|
+
</Text>
|
|
42
|
+
<Text style={textStyle}>
|
|
43
|
+
If something is missing, reply to this message and a person reads it.
|
|
44
|
+
</Text>
|
|
45
|
+
</GameEmail>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
GAME_LINK,
|
|
8
|
+
GAME_NAME,
|
|
9
|
+
GameEmail,
|
|
10
|
+
greeting,
|
|
11
|
+
textStyle,
|
|
12
|
+
} from "./_shell";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sent once a player crosses the lifetime-spend threshold. A thank-you and
|
|
16
|
+
* one perk, and the perk is a standing bonus because it is worth something
|
|
17
|
+
* every time they buy and takes one line to explain.
|
|
18
|
+
*
|
|
19
|
+
* No tier name and no list of benefits: this is a note about them, not the
|
|
20
|
+
* launch of a loyalty programme. Change the 10% here and the threshold in
|
|
21
|
+
* `journeys/spender-care.ts` together.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const props = z.object({
|
|
25
|
+
firstName: z.string().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const subject: Subject<typeof props> = () =>
|
|
29
|
+
"10% extra on everything you buy";
|
|
30
|
+
|
|
31
|
+
export const sendClass: SendClass = "marketing";
|
|
32
|
+
|
|
33
|
+
export const tags = ["game", "revenue"];
|
|
34
|
+
|
|
35
|
+
export default function SpenderThanks(p: z.infer<typeof props>): ReactElement {
|
|
36
|
+
return (
|
|
37
|
+
<GameEmail
|
|
38
|
+
preview="A standing bonus on every purchase."
|
|
39
|
+
heading={greeting(p.firstName)}
|
|
40
|
+
>
|
|
41
|
+
<Text style={textStyle}>
|
|
42
|
+
You are one of the handful of players who keep {GAME_NAME} running, and
|
|
43
|
+
we noticed.
|
|
44
|
+
</Text>
|
|
45
|
+
<Text style={textStyle}>
|
|
46
|
+
From now on every purchase you make includes 10% extra of whatever is in
|
|
47
|
+
it. There is nothing to enter and no expiry; it is already on your
|
|
48
|
+
account.
|
|
49
|
+
</Text>
|
|
50
|
+
<Cta href={GAME_LINK}>Open the game</Cta>
|
|
51
|
+
</GameEmail>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -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 { Cta, GAME_LINK, GameEmail, greeting, textStyle } from "./_shell";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sent after three failures in a row. It leads with the hint and mentions
|
|
9
|
+
* the offer once, in that order: a mail that sells a second life to
|
|
10
|
+
* somebody who is losing reads as a toll booth, and the same mail that
|
|
11
|
+
* helps first reads as help.
|
|
12
|
+
*
|
|
13
|
+
* `level` comes off the `level.failed` event your game sends. The subject
|
|
14
|
+
* and the first line both work without it, because a game that sends no
|
|
15
|
+
* level number should still get a usable mail.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const props = z.object({
|
|
19
|
+
firstName: z.string().optional(),
|
|
20
|
+
level: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const subject: Subject<typeof props> = (p) =>
|
|
24
|
+
p.level ? `A hint for level ${p.level}` : "A hint for that level";
|
|
25
|
+
|
|
26
|
+
export const sendClass: SendClass = "marketing";
|
|
27
|
+
|
|
28
|
+
export const tags = ["game", "help"];
|
|
29
|
+
|
|
30
|
+
export default function StuckHint(p: z.infer<typeof props>): ReactElement {
|
|
31
|
+
return (
|
|
32
|
+
<GameEmail
|
|
33
|
+
preview="Most people clear it on the next try."
|
|
34
|
+
heading={greeting(p.firstName)}
|
|
35
|
+
>
|
|
36
|
+
<Text style={textStyle}>
|
|
37
|
+
{p.level
|
|
38
|
+
? `Level ${p.level} catches most people a few times. Two things help: upgrade your boots before you start, and keep the shield for the last stretch rather than opening with it.`
|
|
39
|
+
: "That level catches most people a few times. Two things help: upgrade your boots before you start, and keep the shield for the last stretch rather than opening with it."}
|
|
40
|
+
</Text>
|
|
41
|
+
<Text style={textStyle}>
|
|
42
|
+
If it still will not go, the starter pack gives you a second life on
|
|
43
|
+
every run for a week.
|
|
44
|
+
</Text>
|
|
45
|
+
<Cta href={GAME_LINK}>Try the level again</Cta>
|
|
46
|
+
</GameEmail>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -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
|
+
GAME_LINK,
|
|
8
|
+
GAME_NAME,
|
|
9
|
+
GameEmail,
|
|
10
|
+
greeting,
|
|
11
|
+
textStyle,
|
|
12
|
+
} from "./_shell";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sent a day after someone played and stopped partway through the
|
|
16
|
+
* tutorial. It says how long finishing takes and what opens up after it,
|
|
17
|
+
* because "come back" is not a reason and "two minutes" is.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const props = z.object({
|
|
21
|
+
firstName: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const subject: Subject<typeof props> = () =>
|
|
25
|
+
"Two minutes left in the tutorial";
|
|
26
|
+
|
|
27
|
+
export const sendClass: SendClass = "marketing";
|
|
28
|
+
|
|
29
|
+
export const tags = ["game", "onboarding"];
|
|
30
|
+
|
|
31
|
+
export default function TutorialNudge(p: z.infer<typeof props>): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<GameEmail
|
|
34
|
+
preview="Your progress is saved where you stopped."
|
|
35
|
+
heading={greeting(p.firstName)}
|
|
36
|
+
>
|
|
37
|
+
<Text style={textStyle}>
|
|
38
|
+
You started {GAME_NAME} and stopped partway through the tutorial. There
|
|
39
|
+
are about two minutes left in it, and the map, the workshop and
|
|
40
|
+
everything else open up at the end.
|
|
41
|
+
</Text>
|
|
42
|
+
<Text style={textStyle}>
|
|
43
|
+
Your progress is saved, so you start where you stopped.
|
|
44
|
+
</Text>
|
|
45
|
+
<Cta href={GAME_LINK}>Finish the tutorial</Cta>
|
|
46
|
+
</GameEmail>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -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 how long a
|
|
8
|
+
* player has to come back before the return mail goes out is one edit in
|
|
9
|
+
* `lapsed-player.ts` and not a hunt through a shared config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A trait or event property, which arrives as `unknown`, as a string. */
|
|
13
|
+
export const str = (value: unknown): string | undefined =>
|
|
14
|
+
typeof value === "string" ? value : undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Day two: they finished the tutorial and have not opened the game since.
|
|
6
|
+
* The single biggest drop in a mobile game is between the first session
|
|
7
|
+
* and the second, and this is the mail that sits in it.
|
|
8
|
+
*
|
|
9
|
+
* `tutorial.completed` is the trigger rather than `session.started`,
|
|
10
|
+
* because a session ends where the player stops and nothing says when that
|
|
11
|
+
* was; finishing the tutorial is the one moment the game knows the first
|
|
12
|
+
* session went well.
|
|
13
|
+
*
|
|
14
|
+
* 36 hours, not 24: someone who plays after work is not gone at the same
|
|
15
|
+
* hour the next day, and a mail that arrives while they are still playing
|
|
16
|
+
* is worse than no mail.
|
|
17
|
+
*
|
|
18
|
+
* `marketing` with no `enrollment`. The tutorial is finished once, so this
|
|
19
|
+
* journey runs once even without the rule, and the rule is what keeps it
|
|
20
|
+
* that way if you ever point the trigger at something repeatable.
|
|
21
|
+
*/
|
|
22
|
+
export default defineJourney({
|
|
23
|
+
trigger: { event: "tutorial.completed" },
|
|
24
|
+
purpose: "marketing",
|
|
25
|
+
from: "Acme Quest <play@example.com>",
|
|
26
|
+
description: "Invites a new player back for a second session.",
|
|
27
|
+
tags: ["game", "retention"],
|
|
28
|
+
run: async (_event, api) => {
|
|
29
|
+
if (await api.waitForEvent("session.started", { timeout: "36h" })) {
|
|
30
|
+
// They came back by themselves, which is the outcome this mail was
|
|
31
|
+
// for. Sending it now would be telling them to do what they did.
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const me = await api.profile.get();
|
|
35
|
+
await api.send.email({
|
|
36
|
+
template: "day-two-return",
|
|
37
|
+
props: { firstName: str(me.traits.firstName) },
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The first purchase, confirmed. It enrolls from
|
|
6
|
+
* `segments/paying-players.ts` rather than from `purchase.completed`, so
|
|
7
|
+
* "has paid at all" is the audience's job and this file has no counting to
|
|
8
|
+
* do: a player crosses that line once, which is what makes this the first
|
|
9
|
+
* purchase and not every purchase.
|
|
10
|
+
*
|
|
11
|
+
* `transactional`, the only one of the six that is. The player paid for
|
|
12
|
+
* something and this says it arrived, which is why it is not gated on
|
|
13
|
+
* marketing consent and why the journey takes no cooldown.
|
|
14
|
+
*
|
|
15
|
+
* That is also the line to watch, and in a game it is thin. One mail about
|
|
16
|
+
* what they bought is transactional. The same mail with a second offer
|
|
17
|
+
* under it is marketing wearing a receipt: the follow-on belongs in
|
|
18
|
+
* `spender-care.ts`, which is marketing and says so.
|
|
19
|
+
*/
|
|
20
|
+
export default defineJourney({
|
|
21
|
+
trigger: { segment: "paying-players" },
|
|
22
|
+
purpose: "transactional",
|
|
23
|
+
from: "Acme Quest <receipts@example.com>",
|
|
24
|
+
description:
|
|
25
|
+
"Confirms a player's first in-app purchase reached their account.",
|
|
26
|
+
tags: ["game", "revenue"],
|
|
27
|
+
run: async (_event, api) => {
|
|
28
|
+
const me = await api.profile.get();
|
|
29
|
+
await api.send.email({
|
|
30
|
+
template: "purchase-receipt",
|
|
31
|
+
props: { firstName: str(me.traits.firstName) },
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The return sequence, in two touches, stopping the moment they play.
|
|
6
|
+
*
|
|
7
|
+
* It enrolls from `segments/lapsed-players.ts`, so what counts as lapsed
|
|
8
|
+
* lives in one file and this journey only decides what to do about it.
|
|
9
|
+
* Change the 30 days there, not here.
|
|
10
|
+
*
|
|
11
|
+
* The reminder goes first and carries nothing: a good number of people
|
|
12
|
+
* come back for the reminder alone, and a game that opens with free
|
|
13
|
+
* currency teaches its players to stop playing and wait for it. The offer
|
|
14
|
+
* is the second touch, a week later, and only for someone who ignored the
|
|
15
|
+
* first — the `waitForEvent` is what draws that line, and it ends the
|
|
16
|
+
* journey on the session rather than after it.
|
|
17
|
+
*
|
|
18
|
+
* `marketing` with a 180-day cooldown, so a player who drifts away again
|
|
19
|
+
* next year is invited back again, and one who drifts twice in a season is
|
|
20
|
+
* not.
|
|
21
|
+
*/
|
|
22
|
+
export default defineJourney({
|
|
23
|
+
trigger: { segment: "lapsed-players" },
|
|
24
|
+
purpose: "marketing",
|
|
25
|
+
enrollment: { cooldown: "180d" },
|
|
26
|
+
from: "Acme Quest <play@example.com>",
|
|
27
|
+
description: "Invites a player back who has not opened the game in a month.",
|
|
28
|
+
tags: ["game", "lifecycle"],
|
|
29
|
+
run: async (_event, api) => {
|
|
30
|
+
const me = await api.profile.get();
|
|
31
|
+
const props = { firstName: str(me.traits.firstName) };
|
|
32
|
+
await api.send.email({ template: "lapsed-reminder", props });
|
|
33
|
+
if (await api.waitForEvent("session.started", { timeout: "7d" })) {
|
|
34
|
+
// They played. The offer was for the people who did not.
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await api.send.email({ template: "lapsed-offer", props });
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Onboarding: they played, they did not finish the tutorial. One nudge,
|
|
6
|
+
* sent once.
|
|
7
|
+
*
|
|
8
|
+
* The wait is the whole journey. Enrolling gives the player a day to
|
|
9
|
+
* finish on their own, and `waitForEvent` is how you spend that day: it
|
|
10
|
+
* resolves the moment `tutorial.completed` arrives and returns `null` when
|
|
11
|
+
* the 24 hours are up, so the nudge is only ever sent to someone who is
|
|
12
|
+
* still stuck. A `sleep("24h")` followed by a check would mail the player
|
|
13
|
+
* who finished twenty minutes in.
|
|
14
|
+
*
|
|
15
|
+
* `marketing` with no `enrollment`, so one player is nudged once, ever,
|
|
16
|
+
* however many times they re-enter `new-players`.
|
|
17
|
+
*/
|
|
18
|
+
export default defineJourney({
|
|
19
|
+
trigger: { segment: "new-players" },
|
|
20
|
+
purpose: "marketing",
|
|
21
|
+
from: "Acme Quest <play@example.com>",
|
|
22
|
+
description: "Nudges a new player who stopped partway through the tutorial.",
|
|
23
|
+
tags: ["game", "onboarding"],
|
|
24
|
+
run: async (_event, api) => {
|
|
25
|
+
if (await api.waitForEvent("tutorial.completed", { timeout: "24h" })) {
|
|
26
|
+
// They finished on their own. Nothing to say.
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const me = await api.profile.get();
|
|
30
|
+
await api.send.email({
|
|
31
|
+
template: "tutorial-nudge",
|
|
32
|
+
props: { firstName: str(me.traits.firstName) },
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A player crosses the lifetime-spend threshold and hears about it: one
|
|
6
|
+
* thank-you carrying one perk.
|
|
7
|
+
*
|
|
8
|
+
* The trigger is a segment written out here rather than in `segments/`,
|
|
9
|
+
* because no other journey wants this audience (ADR 0023). The four in
|
|
10
|
+
* `segments/` are shared; this one is the journey's own.
|
|
11
|
+
*
|
|
12
|
+
* 50 is the threshold, and it is a number your game tells Cowliss: send
|
|
13
|
+
* the running total on identify as `lifetimeSpend` and this trigger
|
|
14
|
+
* follows it. Pick a figure only a small share of your players reach,
|
|
15
|
+
* because the mail below promises them attention.
|
|
16
|
+
*
|
|
17
|
+
* `marketing` with no `enrollment`, so crossing the threshold is worth one
|
|
18
|
+
* mail, once. This is also where the follow-on to a purchase lives: it is
|
|
19
|
+
* an offer, it is not owed to anybody, and it is gated on consent.
|
|
20
|
+
*/
|
|
21
|
+
export default defineJourney({
|
|
22
|
+
trigger: {
|
|
23
|
+
segment: {
|
|
24
|
+
match: "all",
|
|
25
|
+
predicates: [
|
|
26
|
+
{ kind: "trait", name: "lifetimeSpend", op: "gte", value: 50 },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
purpose: "marketing",
|
|
31
|
+
from: "Acme Quest <play@example.com>",
|
|
32
|
+
description: "Thanks a player who has spent past the threshold, with a perk.",
|
|
33
|
+
tags: ["game", "revenue"],
|
|
34
|
+
run: async (_event, api) => {
|
|
35
|
+
const me = await api.profile.get();
|
|
36
|
+
await api.send.email({
|
|
37
|
+
template: "spender-thanks",
|
|
38
|
+
props: { firstName: str(me.traits.firstName) },
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineJourney } from "@cowliss/cli/journeys";
|
|
2
|
+
import { str } from "./_helpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stuck: three failures in a row, no clear in between. A player who keeps
|
|
6
|
+
* losing is one session away from deleting the game, and a hint that
|
|
7
|
+
* arrives while they are still on that level is the one piece of mail a
|
|
8
|
+
* game can send that reads as help rather than marketing.
|
|
9
|
+
*
|
|
10
|
+
* The wait takes a list, which is a disjunction: it resolves on whichever
|
|
11
|
+
* of the two arrives first, and the resolved event's `name` says which.
|
|
12
|
+
* That is how "failed again" is told apart from "cleared it" without two
|
|
13
|
+
* waits racing each other.
|
|
14
|
+
*
|
|
15
|
+
* It counts failures rather than comparing `properties.level` across them.
|
|
16
|
+
* Three failures inside two hours is one session going badly whichever
|
|
17
|
+
* levels they were, and a branch on an event property is one `cow test`
|
|
18
|
+
* cannot script, because a scenario names an event and not its properties.
|
|
19
|
+
*
|
|
20
|
+
* Two literals: the two-hour window between failures (long enough to
|
|
21
|
+
* survive a trip through a lobby screen, short enough that yesterday's
|
|
22
|
+
* failure does not count) and the three failures themselves, which is the
|
|
23
|
+
* second `waitForEvent` plus the trigger.
|
|
24
|
+
*
|
|
25
|
+
* `marketing` with a 7-day cooldown, so a player who struggles all week
|
|
26
|
+
* hears from you once rather than every evening.
|
|
27
|
+
*/
|
|
28
|
+
export default defineJourney({
|
|
29
|
+
trigger: { event: "level.failed" },
|
|
30
|
+
purpose: "marketing",
|
|
31
|
+
enrollment: { cooldown: "7d" },
|
|
32
|
+
from: "Acme Quest <play@example.com>",
|
|
33
|
+
description: "Sends a hint to a player who keeps failing the same run.",
|
|
34
|
+
tags: ["game", "help"],
|
|
35
|
+
run: async (event, api) => {
|
|
36
|
+
// The level belongs to your game, so the trigger event carries it; the
|
|
37
|
+
// mail drops the number when the event did not name one.
|
|
38
|
+
const level = str(event.properties.level);
|
|
39
|
+
const second = await api.waitForEvent(["level.failed", "level.completed"], {
|
|
40
|
+
timeout: "2h",
|
|
41
|
+
});
|
|
42
|
+
if (second?.name !== "level.failed") {
|
|
43
|
+
// They cleared it, or they stopped playing. Either way, no hint.
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const third = await api.waitForEvent(["level.failed", "level.completed"], {
|
|
47
|
+
timeout: "2h",
|
|
48
|
+
});
|
|
49
|
+
if (third?.name !== "level.failed") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const me = await api.profile.get();
|
|
53
|
+
await api.send.email({
|
|
54
|
+
template: "stuck-hint",
|
|
55
|
+
props: { firstName: str(me.traits.firstName), level },
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"user": {
|
|
3
|
+
"id": "usr_1",
|
|
4
|
+
"traits": {
|
|
5
|
+
"email": "ada@example.com",
|
|
6
|
+
"firstName": "Ada"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"events": [],
|
|
10
|
+
"expect": [
|
|
11
|
+
{
|
|
12
|
+
"activity": "sendEmail",
|
|
13
|
+
"input": {
|
|
14
|
+
"template": "day-two-return",
|
|
15
|
+
"props": {
|
|
16
|
+
"firstName": "Ada"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"user": {
|
|
3
|
+
"id": "usr_1",
|
|
4
|
+
"traits": {
|
|
5
|
+
"email": "ada@example.com",
|
|
6
|
+
"firstName": "Ada"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"events": [],
|
|
10
|
+
"expect": [
|
|
11
|
+
{
|
|
12
|
+
"activity": "sendEmail",
|
|
13
|
+
"input": {
|
|
14
|
+
"template": "purchase-receipt",
|
|
15
|
+
"props": {
|
|
16
|
+
"firstName": "Ada"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|