@stacksjs/defaults 0.74.2 → 0.74.3
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/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
- package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +5 -0
- package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { Campaign } from '@stacksjs/orm'
|
|
5
|
+
import { response } from '@stacksjs/router'
|
|
6
|
+
import { recoveryCampaignWriteData, validateRecoveryCampaign } from './abandoned-cart-records'
|
|
7
|
+
import { marketingModelError } from './marketing-response'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write the campaign that goes after the cold carts.
|
|
11
|
+
*
|
|
12
|
+
* It is an ordinary Campaign row - the send pipeline, the delivery reporting
|
|
13
|
+
* and the campaigns screen all work on it unchanged. What makes it a recovery
|
|
14
|
+
* campaign is the segment stored on it, which names the trigger and the rules
|
|
15
|
+
* it was written with, so the campaign still says who it was for long after
|
|
16
|
+
* those carts have converted or been swept.
|
|
17
|
+
*
|
|
18
|
+
* Created as a draft, or scheduled if a time was given. Nothing sends from
|
|
19
|
+
* here: delivery goes through `CampaignSendAction` like every other campaign,
|
|
20
|
+
* so there is one place where mail actually leaves.
|
|
21
|
+
*/
|
|
22
|
+
export default new Action({
|
|
23
|
+
name: 'AbandonedCartCampaignAction',
|
|
24
|
+
description: 'Creates a cart-recovery campaign aimed at customers who left a cart behind.',
|
|
25
|
+
method: 'POST',
|
|
26
|
+
model: Campaign,
|
|
27
|
+
|
|
28
|
+
async handle(request: RequestInstance) {
|
|
29
|
+
const data = recoveryCampaignWriteData(
|
|
30
|
+
await request.all(),
|
|
31
|
+
String(config.commerce?.currency || 'USD').toUpperCase(),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const validationError = validateRecoveryCampaign(data)
|
|
35
|
+
if (validationError)
|
|
36
|
+
return response.json({ message: validationError }, 422)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const campaign = await Campaign.create({
|
|
40
|
+
...data,
|
|
41
|
+
audience_size: 0,
|
|
42
|
+
sent_count: 0,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return response.json({ id: campaign.get('id') }, 201)
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return marketingModelError(error, 'Recovery campaign could not be created.', 'AbandonedCartCampaignAction')
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { config } from '@stacksjs/config'
|
|
3
|
+
import { db } from '@stacksjs/database'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { normalizeAbandonedCarts } from './abandoned-cart-records'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every cart that was filled and then left, and whatever has been done about it.
|
|
9
|
+
*
|
|
10
|
+
* Read straight from `carts` rather than from a rollup: the number a shop acts
|
|
11
|
+
* on has to be the number in the table, and a cached one drifts the moment
|
|
12
|
+
* somebody checks out.
|
|
13
|
+
*
|
|
14
|
+
* `converted` carts are fetched alongside the cold ones because recovery is
|
|
15
|
+
* only measurable as the difference between them - a dashboard that shows
|
|
16
|
+
* only what is still abandoned can say how much money is sitting there and
|
|
17
|
+
* never how much of it came back.
|
|
18
|
+
*/
|
|
19
|
+
export default new Action({
|
|
20
|
+
name: 'AbandonedCartIndexAction',
|
|
21
|
+
description: 'Returns abandoned carts, their recovery campaigns, and what those campaigns brought back.',
|
|
22
|
+
method: 'GET',
|
|
23
|
+
apiResponse: true,
|
|
24
|
+
|
|
25
|
+
async handle() {
|
|
26
|
+
try {
|
|
27
|
+
const carts = await db
|
|
28
|
+
.selectFrom('carts')
|
|
29
|
+
.selectAll()
|
|
30
|
+
.where('status', 'in', ['abandoned', 'expired', 'converted'])
|
|
31
|
+
.orderBy('updated_at', 'desc')
|
|
32
|
+
.limit(500)
|
|
33
|
+
.execute()
|
|
34
|
+
|
|
35
|
+
const cartIds = carts.map(cart => Number(cart.id)).filter(Number.isFinite)
|
|
36
|
+
const customerIds = [...new Set(
|
|
37
|
+
carts.map(cart => Number(cart.customer_id)).filter(id => Number.isFinite(id) && id > 0),
|
|
38
|
+
)]
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Both of these are `in` lookups on the carts already fetched, so an
|
|
42
|
+
* empty page asks nothing rather than selecting every row in the table
|
|
43
|
+
* behind an `in ()` that some drivers read as "no filter".
|
|
44
|
+
*/
|
|
45
|
+
const [items, customers, campaigns] = await Promise.all([
|
|
46
|
+
cartIds.length > 0
|
|
47
|
+
? db.selectFrom('cart_items')
|
|
48
|
+
.select(['cart_id', 'quantity', 'product_name'])
|
|
49
|
+
.where('cart_id', 'in', cartIds)
|
|
50
|
+
.execute()
|
|
51
|
+
: Promise.resolve([]),
|
|
52
|
+
customerIds.length > 0
|
|
53
|
+
? db.selectFrom('customers')
|
|
54
|
+
.select(['id', 'name', 'email'])
|
|
55
|
+
.where('id', 'in', customerIds)
|
|
56
|
+
.execute()
|
|
57
|
+
: Promise.resolve([]),
|
|
58
|
+
db.selectFrom('campaigns')
|
|
59
|
+
.selectAll()
|
|
60
|
+
.whereNotNull('segment_definition')
|
|
61
|
+
.orderBy('id', 'desc')
|
|
62
|
+
.limit(200)
|
|
63
|
+
.execute(),
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
// Only the sends belonging to those campaigns matter, and there is no
|
|
67
|
+
// point reading a newsletter's hundred thousand rows to find out.
|
|
68
|
+
const campaignIds = campaigns.map(campaign => Number(campaign.id)).filter(Number.isFinite)
|
|
69
|
+
const sends = campaignIds.length > 0
|
|
70
|
+
? await db.selectFrom('campaign_sends')
|
|
71
|
+
.select(['campaign_id', 'recipient', 'sent_at', 'created_at'])
|
|
72
|
+
.where('campaign_id', 'in', campaignIds)
|
|
73
|
+
.where('status', 'in', ['sent', 'delivered'])
|
|
74
|
+
.execute()
|
|
75
|
+
: []
|
|
76
|
+
|
|
77
|
+
return normalizeAbandonedCarts(carts, items, customers, campaigns, sends, {
|
|
78
|
+
defaultCurrency: String(config.commerce?.currency || 'USD').toUpperCase(),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return dashboardOperationalError(error, 'Abandoned carts could not be loaded.', 'AbandonedCartIndexAction')
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
})
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
abandonedCartSegment,
|
|
4
|
+
isRecoverySegment,
|
|
5
|
+
normalizeAbandonedCarts,
|
|
6
|
+
reachOf,
|
|
7
|
+
recoveryCampaignWriteData,
|
|
8
|
+
validateRecoveryCampaign,
|
|
9
|
+
} from './abandoned-cart-records'
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-09-01T18:00:00.000Z')
|
|
12
|
+
|
|
13
|
+
/** A cart, as the driver hands it over. */
|
|
14
|
+
function cart(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
15
|
+
return {
|
|
16
|
+
id: 1,
|
|
17
|
+
status: 'abandoned',
|
|
18
|
+
total: 84,
|
|
19
|
+
total_items: 3,
|
|
20
|
+
currency: 'usd',
|
|
21
|
+
customer_id: 11,
|
|
22
|
+
updated_at: '2026-09-01 06:00:00',
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CUSTOMERS = [
|
|
28
|
+
{ id: 11, name: 'Rosa Klein', email: 'Rosa@example.com' },
|
|
29
|
+
{ id: 12, name: 'Amir Haddad', email: 'amir@example.com' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const RECOVERY_CAMPAIGN = {
|
|
33
|
+
id: 5,
|
|
34
|
+
name: 'Left something behind',
|
|
35
|
+
status: 'sent',
|
|
36
|
+
sent_count: 40,
|
|
37
|
+
segment_definition: JSON.stringify(abandonedCartSegment(6, 25)),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('abandoned cart records', () => {
|
|
41
|
+
test('reads what was left behind, and how long ago', () => {
|
|
42
|
+
const result = normalizeAbandonedCarts(
|
|
43
|
+
[cart()],
|
|
44
|
+
[
|
|
45
|
+
{ cart_id: 1, quantity: 2, product_name: 'Pour-over kettle' },
|
|
46
|
+
{ cart_id: 1, quantity: 1, product_name: 'Burr grinder' },
|
|
47
|
+
],
|
|
48
|
+
CUSTOMERS,
|
|
49
|
+
[],
|
|
50
|
+
[],
|
|
51
|
+
{ now: NOW },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
expect(result.records[0]).toMatchObject({
|
|
55
|
+
customerName: 'Rosa Klein',
|
|
56
|
+
itemCount: 3,
|
|
57
|
+
items: ['Pour-over kettle', 'Burr grinder'],
|
|
58
|
+
value: 84,
|
|
59
|
+
currency: 'USD',
|
|
60
|
+
idleHours: 12,
|
|
61
|
+
state: 'abandoned',
|
|
62
|
+
contacted: false,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a bare database timestamp is read as UTC, not as local time', () => {
|
|
67
|
+
const result = normalizeAbandonedCarts(
|
|
68
|
+
// Two hours before NOW, written the way SQLite writes CURRENT_TIMESTAMP.
|
|
69
|
+
[cart({ updated_at: '2026-09-01 16:00:00' })],
|
|
70
|
+
[],
|
|
71
|
+
CUSTOMERS,
|
|
72
|
+
[],
|
|
73
|
+
[],
|
|
74
|
+
{ now: NOW },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Read as local in a UTC-7 browser this is five hours in the future,
|
|
78
|
+
// which clamps to "idle 0h" and empties every age filter on the page.
|
|
79
|
+
expect(result.records[0].idleHours).toBe(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a timestamp that carries its own zone is left alone', () => {
|
|
83
|
+
const result = normalizeAbandonedCarts(
|
|
84
|
+
[cart({ updated_at: '2026-09-01T16:00:00+02:00' })],
|
|
85
|
+
[],
|
|
86
|
+
CUSTOMERS,
|
|
87
|
+
[],
|
|
88
|
+
[],
|
|
89
|
+
{ now: NOW },
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result.records[0].idleHours).toBe(4)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a cart with no line items still counts what it says it holds', () => {
|
|
96
|
+
const result = normalizeAbandonedCarts([cart({ total_items: 5 })], [], CUSTOMERS, [], [], { now: NOW })
|
|
97
|
+
|
|
98
|
+
expect(result.records[0].itemCount).toBe(5)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a cart without a customer is a guest, not a blank row', () => {
|
|
102
|
+
const result = normalizeAbandonedCarts([cart({ customer_id: null })], [], CUSTOMERS, [], [], { now: NOW })
|
|
103
|
+
|
|
104
|
+
expect(result.records[0]).toMatchObject({ customerName: 'Guest', customerEmail: '' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('only campaigns aimed at carts count as recovery campaigns', () => {
|
|
108
|
+
const result = normalizeAbandonedCarts(
|
|
109
|
+
[],
|
|
110
|
+
[],
|
|
111
|
+
[],
|
|
112
|
+
[
|
|
113
|
+
RECOVERY_CAMPAIGN,
|
|
114
|
+
{ id: 6, name: 'Weekly newsletter', status: 'sent', segment_definition: JSON.stringify({ operator: 'and', rules: [] }) },
|
|
115
|
+
{ id: 7, name: 'No segment at all', status: 'draft', segment_definition: null },
|
|
116
|
+
{ id: 8, name: 'Half-written segment', status: 'draft', segment_definition: '{ not json' },
|
|
117
|
+
],
|
|
118
|
+
[],
|
|
119
|
+
{ now: NOW },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(result.campaigns.map(campaign => campaign.id)).toEqual(['5'])
|
|
123
|
+
// The rules the campaign was written with survive on it, so a sent
|
|
124
|
+
// campaign still says what it was aimed at once those carts are gone.
|
|
125
|
+
expect(result.campaigns[0]).toMatchObject({ idleHours: 6, minimumValue: 25, sentCount: 40 })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('a customer a recovery campaign wrote to is marked as contacted', () => {
|
|
129
|
+
const result = normalizeAbandonedCarts(
|
|
130
|
+
[cart()],
|
|
131
|
+
[],
|
|
132
|
+
CUSTOMERS,
|
|
133
|
+
[RECOVERY_CAMPAIGN],
|
|
134
|
+
// The address is matched case-insensitively: a send records whatever
|
|
135
|
+
// the customer typed, and `Rosa@` and `rosa@` are one person.
|
|
136
|
+
[{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
|
|
137
|
+
{ now: NOW },
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(result.records[0].contacted).toBe(true)
|
|
141
|
+
expect(result.summary.contacted).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('a send from an ordinary campaign does not count as contact', () => {
|
|
145
|
+
const result = normalizeAbandonedCarts(
|
|
146
|
+
[cart()],
|
|
147
|
+
[],
|
|
148
|
+
CUSTOMERS,
|
|
149
|
+
[{ id: 6, name: 'Weekly newsletter', status: 'sent', segment_definition: '{}' }],
|
|
150
|
+
[{ campaign_id: 6, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
|
|
151
|
+
{ now: NOW },
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// Otherwise every newsletter subscriber with a cold cart would read as
|
|
155
|
+
// somebody the recovery campaign had already reached.
|
|
156
|
+
expect(result.records[0].contacted).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a checkout after the email is credited to the campaign', () => {
|
|
160
|
+
const result = normalizeAbandonedCarts(
|
|
161
|
+
[cart({ status: 'converted', updated_at: '2026-09-01 09:00:00' })],
|
|
162
|
+
[],
|
|
163
|
+
CUSTOMERS,
|
|
164
|
+
[RECOVERY_CAMPAIGN],
|
|
165
|
+
[{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
|
|
166
|
+
{ now: NOW },
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(result.records[0].state).toBe('recovered')
|
|
170
|
+
expect(result.summary).toMatchObject({ recovered: 1, recoveredValue: 84, open: 0, recoveryRate: 100 })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('a checkout before any email is a sale, not a recovery', () => {
|
|
174
|
+
const result = normalizeAbandonedCarts(
|
|
175
|
+
[cart({ status: 'converted', updated_at: '2026-09-01 06:00:00' })],
|
|
176
|
+
[],
|
|
177
|
+
CUSTOMERS,
|
|
178
|
+
[RECOVERY_CAMPAIGN],
|
|
179
|
+
[{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' }],
|
|
180
|
+
{ now: NOW },
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// Somebody who came back on their own is not the campaign's doing, and
|
|
184
|
+
// crediting them would make every recovery campaign look like it worked.
|
|
185
|
+
expect(result.records[0].state).toBe('abandoned')
|
|
186
|
+
expect(result.summary.recovered).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('the rate counts only carts that were actually chased', () => {
|
|
190
|
+
const result = normalizeAbandonedCarts(
|
|
191
|
+
[
|
|
192
|
+
cart({ id: 1, status: 'converted', updated_at: '2026-09-01 09:00:00' }),
|
|
193
|
+
cart({ id: 2, customer_id: 12, total: 40 }),
|
|
194
|
+
// Never written to, so it is not evidence either way.
|
|
195
|
+
cart({ id: 3, customer_id: null, total: 10 }),
|
|
196
|
+
],
|
|
197
|
+
[],
|
|
198
|
+
CUSTOMERS,
|
|
199
|
+
[RECOVERY_CAMPAIGN],
|
|
200
|
+
[
|
|
201
|
+
{ campaign_id: 5, recipient: 'rosa@example.com', sent_at: '2026-09-01 07:00:00' },
|
|
202
|
+
{ campaign_id: 5, recipient: 'amir@example.com', sent_at: '2026-09-01 07:00:00' },
|
|
203
|
+
],
|
|
204
|
+
{ now: NOW },
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// One recovered, one contacted and still cold: 50%, not 33%.
|
|
208
|
+
expect(result.summary).toMatchObject({ open: 2, recovered: 1, contacted: 1, recoveryRate: 50 })
|
|
209
|
+
expect(result.summary.openValue).toBe(50)
|
|
210
|
+
expect(result.summary.averageValue).toBe(25)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('an expired cart is kept, and says so', () => {
|
|
214
|
+
const result = normalizeAbandonedCarts([cart({ status: 'expired' })], [], CUSTOMERS, [], [], { now: NOW })
|
|
215
|
+
|
|
216
|
+
expect(result.records[0].state).toBe('expired')
|
|
217
|
+
expect(result.summary.open).toBe(1)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('nothing to chase divides by nothing', () => {
|
|
221
|
+
const result = normalizeAbandonedCarts([], [], [], [], [], { now: NOW })
|
|
222
|
+
|
|
223
|
+
expect(result.summary).toMatchObject({ open: 0, recovered: 0, recoveryRate: 0, averageValue: 0 })
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('who a recovery campaign would reach', () => {
|
|
228
|
+
const records = normalizeAbandonedCarts(
|
|
229
|
+
[
|
|
230
|
+
cart({ id: 1, total: 84, updated_at: '2026-09-01 06:00:00' }),
|
|
231
|
+
cart({ id: 2, customer_id: 12, total: 12, updated_at: '2026-09-01 06:00:00' }),
|
|
232
|
+
cart({ id: 3, customer_id: 12, total: 90, updated_at: '2026-09-01 17:30:00' }),
|
|
233
|
+
cart({ id: 4, customer_id: null, total: 200, updated_at: '2026-09-01 06:00:00' }),
|
|
234
|
+
],
|
|
235
|
+
[],
|
|
236
|
+
CUSTOMERS,
|
|
237
|
+
[],
|
|
238
|
+
[],
|
|
239
|
+
{ now: NOW },
|
|
240
|
+
).records
|
|
241
|
+
|
|
242
|
+
test('counts only carts old enough and worth enough', () => {
|
|
243
|
+
// Cart 2 is too cheap, cart 3 too fresh, cart 4 has nobody to write to.
|
|
244
|
+
expect(reachOf(records, 6, 25)).toEqual({ carts: 1, value: 84 })
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('widening the rules widens the audience', () => {
|
|
248
|
+
expect(reachOf(records, 1, 0)).toEqual({ carts: 2, value: 96 })
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('composing a recovery campaign', () => {
|
|
253
|
+
test('stores the rules it was written with', () => {
|
|
254
|
+
const data = recoveryCampaignWriteData({
|
|
255
|
+
name: 'Left something behind',
|
|
256
|
+
subject: 'Your cart is still here',
|
|
257
|
+
idleHours: 6,
|
|
258
|
+
minimumValue: 25,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
expect(isRecoverySegment(data.segment_definition)).toBe(true)
|
|
262
|
+
expect(JSON.parse(data.segment_definition).rules).toContainEqual({
|
|
263
|
+
field: 'cart.idleHours',
|
|
264
|
+
operator: 'gte',
|
|
265
|
+
value: 6,
|
|
266
|
+
})
|
|
267
|
+
expect(data).toMatchObject({ type: 'email', status: 'draft', template: 'abandoned-cart' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('a send time makes it scheduled', () => {
|
|
271
|
+
const data = recoveryCampaignWriteData({
|
|
272
|
+
name: 'Left something behind',
|
|
273
|
+
subject: 'Your cart is still here',
|
|
274
|
+
scheduledAt: '2026-09-02 09:00:00',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
expect(data.status).toBe('scheduled')
|
|
278
|
+
expect(validateRecoveryCampaign(data, NOW)).toBe('')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('refuses a send time that has already passed', () => {
|
|
282
|
+
const data = recoveryCampaignWriteData({
|
|
283
|
+
name: 'Left something behind',
|
|
284
|
+
subject: 'Your cart is still here',
|
|
285
|
+
scheduledAt: '2026-08-30 09:00:00',
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
expect(validateRecoveryCampaign(data, NOW)).toContain('future')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('refuses a campaign with nothing to say', () => {
|
|
292
|
+
const data = recoveryCampaignWriteData({ name: 'Left something behind', subject: '' })
|
|
293
|
+
|
|
294
|
+
expect(validateRecoveryCampaign(data, NOW)).toContain('subject')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('refuses to take its audience from an email list', () => {
|
|
298
|
+
const data = recoveryCampaignWriteData({
|
|
299
|
+
name: 'Left something behind',
|
|
300
|
+
subject: 'Your cart is still here',
|
|
301
|
+
emailListId: 7,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// A recovery campaign aimed at a list is a newsletter with a misleading
|
|
305
|
+
// name: it would write to the list, not to the people who left carts.
|
|
306
|
+
expect(validateRecoveryCampaign(data, NOW)).toContain('abandoned carts')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('falls back to a sane idle window rather than zero', () => {
|
|
310
|
+
const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', idleHours: 0 })
|
|
311
|
+
|
|
312
|
+
// An idle window of zero would write to somebody the moment they put
|
|
313
|
+
// something in a cart, while they are still shopping.
|
|
314
|
+
expect(JSON.parse(data.segment_definition).rules[1].value).toBe(4)
|
|
315
|
+
})
|
|
316
|
+
})
|