@stacksjs/defaults 0.74.1 → 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/app/Models/AnalyticsEvent.ts +5 -0
- package/app/Models/Content/Menu.ts +5 -1
- package/app/Models/Content/MenuItem.ts +6 -1
- package/app/Models/Content/Page.ts +5 -1
- package/app/Models/Content/Post.ts +5 -1
- package/app/Models/Content/Redirect.ts +5 -1
- package/app/Models/EmailIdempotency.ts +5 -0
- package/app/Models/EmailList.ts +6 -0
- package/app/Models/EmailSuppression.ts +5 -0
- package/app/Models/EmailWebhookEvent.ts +5 -0
- package/app/Models/Forms/Form.ts +5 -1
- package/app/Models/Forms/FormField.ts +6 -1
- package/app/Models/MailPreference.ts +5 -0
- package/app/Models/Request.ts +5 -0
- package/app/Models/SiteDomain.ts +5 -1
- package/app/Models/Tag.ts +6 -0
- package/app/Models/Team.ts +6 -1
- package/app/Models/User.ts +6 -1
- package/app/Models/commerce/Auction.ts +6 -0
- package/app/Models/commerce/AuctionItem.ts +6 -0
- package/app/Models/commerce/Cart.ts +6 -1
- package/app/Models/commerce/CartItem.ts +6 -1
- package/app/Models/commerce/Category.ts +6 -0
- package/app/Models/commerce/Coupon.ts +6 -0
- package/app/Models/commerce/DeliveryRoute.ts +6 -1
- package/app/Models/commerce/DeliveryStop.ts +6 -1
- package/app/Models/commerce/GiftCard.ts +6 -1
- package/app/Models/commerce/LicenseKey.ts +6 -1
- package/app/Models/commerce/LoyaltyReward.ts +6 -0
- package/app/Models/commerce/Manufacturer.ts +6 -0
- package/app/Models/commerce/Order.ts +6 -1
- package/app/Models/commerce/Payment.ts +6 -1
- package/app/Models/commerce/PrintDevice.ts +6 -0
- package/app/Models/commerce/Product.ts +6 -0
- package/app/Models/commerce/ProductUnit.ts +6 -0
- package/app/Models/commerce/ProductVariant.ts +6 -0
- package/app/Models/commerce/Review.ts +6 -1
- package/app/Models/commerce/ShippingMethod.ts +6 -0
- package/app/Models/commerce/ShippingRate.ts +6 -0
- package/app/Models/commerce/ShippingZone.ts +6 -0
- package/app/Models/commerce/TaxRate.ts +6 -0
- package/app/Models/commerce/Transaction.ts +6 -1
- package/app/Models/commerce/WaitlistProduct.ts +6 -1
- package/app/Models/commerce/WaitlistRestaurant.ts +6 -1
- package/app/Models/realtime/Websocket.ts +5 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/tsconfig.app.json +4 -1
- 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/resources/views/cms/blocks/form.stx +92 -1
- package/routes/dashboard-api.ts +5 -0
- package/routes/forms.ts +46 -0
- package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type {
|
|
3
|
+
AbandonedCartIndexPayload,
|
|
4
|
+
AbandonedCartRecord,
|
|
5
|
+
AbandonedCartSummary,
|
|
6
|
+
RecoveryCampaignRecord,
|
|
7
|
+
} from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
|
|
8
|
+
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
9
|
+
import { pushToast } from '../../../../functions/toasts'
|
|
10
|
+
|
|
11
|
+
const emptySummary: AbandonedCartSummary = {
|
|
12
|
+
open: 0,
|
|
13
|
+
openValue: 0,
|
|
14
|
+
contacted: 0,
|
|
15
|
+
recovered: 0,
|
|
16
|
+
recoveredValue: 0,
|
|
17
|
+
recoveryRate: 0,
|
|
18
|
+
averageValue: 0,
|
|
19
|
+
currency: 'USD',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const records = state<AbandonedCartRecord[]>([])
|
|
23
|
+
const summary = state<AbandonedCartSummary>({ ...emptySummary })
|
|
24
|
+
const campaigns = state<RecoveryCampaignRecord[]>([])
|
|
25
|
+
/*
|
|
26
|
+
* The shop's configured currency, and the fallback for a page with no carts
|
|
27
|
+
* on it. The carts themselves carry their own, and `summary().currency` is
|
|
28
|
+
* the one the numbers on this screen are actually in - a shop trading in
|
|
29
|
+
* euros should not be shown a dollar sign because the framework's default
|
|
30
|
+
* says USD.
|
|
31
|
+
*/
|
|
32
|
+
const defaultCurrency = state('USD')
|
|
33
|
+
const defaultIdleHours = state(4)
|
|
34
|
+
const loading = state(true)
|
|
35
|
+
const loadError = state('')
|
|
36
|
+
const search = state('')
|
|
37
|
+
const stateFilter = state('open')
|
|
38
|
+
const idleFilter = state('all')
|
|
39
|
+
const sort = state('value')
|
|
40
|
+
const page = state(1)
|
|
41
|
+
const perPage = 10
|
|
42
|
+
const dialogOpen = state(false)
|
|
43
|
+
const saving = state(false)
|
|
44
|
+
const saveError = state('')
|
|
45
|
+
|
|
46
|
+
function filteredRecords(): AbandonedCartRecord[] {
|
|
47
|
+
const query = search().trim().toLowerCase()
|
|
48
|
+
const minimumIdle = idleFilter() === 'all' ? 0 : Number(idleFilter())
|
|
49
|
+
|
|
50
|
+
return [...records()]
|
|
51
|
+
.filter((record) => {
|
|
52
|
+
if (stateFilter() === 'open' && record.state === 'recovered')
|
|
53
|
+
return false
|
|
54
|
+
if (stateFilter() === 'cold' && (record.state !== 'abandoned' || record.contacted))
|
|
55
|
+
return false
|
|
56
|
+
if (stateFilter() === 'chased' && !record.contacted)
|
|
57
|
+
return false
|
|
58
|
+
if (stateFilter() === 'recovered' && record.state !== 'recovered')
|
|
59
|
+
return false
|
|
60
|
+
if (record.idleHours < minimumIdle)
|
|
61
|
+
return false
|
|
62
|
+
return !query || [record.customerName, record.customerEmail, record.id, ...record.items]
|
|
63
|
+
.some(value => String(value).toLowerCase().includes(query))
|
|
64
|
+
})
|
|
65
|
+
.sort((left, right) => {
|
|
66
|
+
if (sort() === 'idle')
|
|
67
|
+
return right.idleHours - left.idleHours
|
|
68
|
+
if (sort() === 'customer')
|
|
69
|
+
return left.customerName.localeCompare(right.customerName)
|
|
70
|
+
return right.value - left.value || right.idleHours - left.idleHours
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function visibleRecords(): AbandonedCartRecord[] {
|
|
75
|
+
const start = (page() - 1) * perPage
|
|
76
|
+
return filteredRecords().slice(start, start + perPage)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function totalPages(): number {
|
|
80
|
+
return Math.max(1, Math.ceil(filteredRecords().length / perPage))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
effect(() => {
|
|
84
|
+
search()
|
|
85
|
+
stateFilter()
|
|
86
|
+
idleFilter()
|
|
87
|
+
sort()
|
|
88
|
+
page.set(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
function money(value: number): string {
|
|
92
|
+
try {
|
|
93
|
+
return new Intl.NumberFormat(undefined, {
|
|
94
|
+
style: 'currency',
|
|
95
|
+
currency: summary().currency || defaultCurrency() || 'USD',
|
|
96
|
+
maximumFractionDigits: 0,
|
|
97
|
+
}).format(value)
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return `${Math.round(value)} ${defaultCurrency()}`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function percentage(value: number): string {
|
|
105
|
+
return `${value.toFixed(1)}%`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function campaignTiming(campaign: RecoveryCampaignRecord): string {
|
|
109
|
+
const stamp = campaign.sentAt || campaign.scheduledAt
|
|
110
|
+
if (!stamp)
|
|
111
|
+
return 'Not scheduled'
|
|
112
|
+
const date = new Date(stamp.replace(' ', 'T'))
|
|
113
|
+
if (!Number.isFinite(date.getTime()))
|
|
114
|
+
return 'Not scheduled'
|
|
115
|
+
const when = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
116
|
+
return campaign.sentAt ? `Sent ${when}` : `Scheduled ${when}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function loadCarts(): Promise<void> {
|
|
120
|
+
loading.set(true)
|
|
121
|
+
loadError.set('')
|
|
122
|
+
try {
|
|
123
|
+
const data = await dashboardApi<AbandonedCartIndexPayload>('/api/dashboard/marketing/abandoned-carts')
|
|
124
|
+
if (!data || !Array.isArray(data.records) || !data.summary || !Array.isArray(data.campaigns))
|
|
125
|
+
throw new TypeError('Abandoned cart resources were not returned in the expected shape.')
|
|
126
|
+
records.set(data.records)
|
|
127
|
+
summary.set(data.summary)
|
|
128
|
+
campaigns.set(data.campaigns)
|
|
129
|
+
defaultCurrency.set(data.defaultCurrency || 'USD')
|
|
130
|
+
defaultIdleHours.set(data.defaultIdleHours || 4)
|
|
131
|
+
if (page() > totalPages())
|
|
132
|
+
page.set(totalPages())
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
loadError.set(error instanceof Error ? error.message : String(error))
|
|
136
|
+
pushToast('error', 'Could not load abandoned carts', { detail: loadError() })
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
loading.set(false)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function openCompose(): void {
|
|
144
|
+
saveError.set('')
|
|
145
|
+
dialogOpen.set(true)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function closeCompose(): void {
|
|
149
|
+
if (saving())
|
|
150
|
+
return
|
|
151
|
+
dialogOpen.set(false)
|
|
152
|
+
saveError.set('')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function createCampaign(payload: Record<string, unknown>): Promise<void> {
|
|
156
|
+
saving.set(true)
|
|
157
|
+
saveError.set('')
|
|
158
|
+
try {
|
|
159
|
+
await dashboardApi('/api/dashboard/marketing/abandoned-carts/campaign', { method: 'POST', body: payload })
|
|
160
|
+
dialogOpen.set(false)
|
|
161
|
+
pushToast('success', payload.scheduledAt ? 'Recovery campaign scheduled' : 'Recovery campaign saved as a draft')
|
|
162
|
+
await loadCarts()
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
saveError.set(error instanceof Error ? error.message : String(error))
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
saving.set(false)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function previousPage(): void {
|
|
173
|
+
if (page() > 1)
|
|
174
|
+
page.set(page() - 1)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nextPage(): void {
|
|
178
|
+
if (page() < totalPages())
|
|
179
|
+
page.set(page() + 1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
onMount(() => {
|
|
183
|
+
void loadCarts()
|
|
184
|
+
})
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<div class="space-y-6">
|
|
188
|
+
<header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
189
|
+
<div>
|
|
190
|
+
<h1 class="font-bold text-2xl text-gray-900 dark:text-white">Abandoned Carts</h1>
|
|
191
|
+
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">The audience that already chose the products. Every other campaign starts by guessing.</p>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="flex gap-2">
|
|
194
|
+
<Button :loading="loading()" variant="secondary" @click="loadCarts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
|
|
195
|
+
<Button @click="openCompose()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-mail-send-01"></span>New recovery campaign</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</header>
|
|
198
|
+
|
|
199
|
+
<div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadCarts()">Retry</Button></div>
|
|
200
|
+
|
|
201
|
+
<section aria-label="Cart recovery metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-5">
|
|
202
|
+
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Sitting there</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ summary().open.toLocaleString() }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Carts abandoned or expired</p></article>
|
|
203
|
+
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Worth</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ money(summary().openValue) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ money(summary().averageValue) }} average</p></article>
|
|
204
|
+
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Chased</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ summary().contacted.toLocaleString() }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Written to and still cold</p></article>
|
|
205
|
+
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Recovered</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ money(summary().recoveredValue) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ summary().recovered.toLocaleString() }} carts checked out after an email</p></article>
|
|
206
|
+
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Recovery rate</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ percentage(summary().recoveryRate) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Of the carts actually chased</p></article>
|
|
207
|
+
</section>
|
|
208
|
+
|
|
209
|
+
<section :if="campaigns().length > 0" aria-label="Recovery campaigns" class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
210
|
+
<h2 class="font-semibold text-gray-900 text-sm dark:text-white">Recovery campaigns</h2>
|
|
211
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Ordinary campaigns aimed at cold carts. They send, report and are edited on the campaigns screen like any other.</p>
|
|
212
|
+
<ul class="mt-3 divide-gray-200 divide-y dark:divide-neutral-700">
|
|
213
|
+
<template :for="campaign in campaigns">
|
|
214
|
+
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
215
|
+
<div>
|
|
216
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ campaign.name }}</p>
|
|
217
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Carts idle {{ campaign.idleHours }}h or more<span :if="campaign.minimumValue > 0"> · {{ money(campaign.minimumValue) }} and up</span> · {{ campaignTiming(campaign) }}</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="flex gap-3 items-center">
|
|
220
|
+
<p class="text-gray-500 text-xs dark:text-neutral-400">{{ campaign.sentCount.toLocaleString() }} sent</p>
|
|
221
|
+
<span class="inline-flex px-2 py-1 font-medium text-xs bg-gray-100 dark:bg-neutral-700 rounded-full">{{ campaign.status }}</span>
|
|
222
|
+
</div>
|
|
223
|
+
</li>
|
|
224
|
+
</template>
|
|
225
|
+
</ul>
|
|
226
|
+
</section>
|
|
227
|
+
|
|
228
|
+
<section class="space-y-3">
|
|
229
|
+
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
|
|
230
|
+
<label><span class="sr-only">Search carts</span><input x-model="search" type="search" placeholder="Search by customer or product" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
|
|
231
|
+
<label><span class="sr-only">Filter cart state</span><select x-model="stateFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="open">Still out there</option><option value="cold">Never chased</option><option value="chased">Already chased</option><option value="recovered">Recovered</option><option value="all">Everything</option></select></label>
|
|
232
|
+
<label><span class="sr-only">Filter by how long the cart has been idle</span><select x-model="idleFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="all">Any age</option><option value="4">Idle 4h or more</option><option value="24">Idle a day or more</option><option value="72">Idle 3 days or more</option></select></label>
|
|
233
|
+
<label><span class="sr-only">Sort carts</span><select x-model="sort" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="value">Highest value</option><option value="idle">Longest idle</option><option value="customer">Customer</option></select></label>
|
|
234
|
+
</div>
|
|
235
|
+
<AbandonedCartsTable :records="visibleRecords()" :loading="loading()" />
|
|
236
|
+
<div :if="filteredRecords().length > perPage" class="flex items-center justify-between"><p class="text-gray-500 text-sm dark:text-neutral-400">Page {{ page() }} of {{ totalPages() }}</p><div class="flex gap-2"><Button :disabled="page() === 1" variant="secondary" @click="previousPage()">Previous</Button><Button :disabled="page() === totalPages()" variant="secondary" @click="nextPage()">Next</Button></div></div>
|
|
237
|
+
</section>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<RecoveryCampaignDialog :open="dialogOpen()" :records="records()" :defaultIdleHours="defaultIdleHours()" :defaultCurrency="summary().currency || defaultCurrency()" :busy="saving()" :error="saveError()" @submit="createCampaign" @close="closeCompose()" />
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
|
|
3
|
+
|
|
4
|
+
const records = useReactiveProp('records', [] as AbandonedCartRecord[])
|
|
5
|
+
const loading = useReactiveProp('loading', false)
|
|
6
|
+
|
|
7
|
+
function money(value: number, currency: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(value)
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// A currency the browser does not know is not a reason to render nothing
|
|
13
|
+
// where a number belongs.
|
|
14
|
+
return `${value.toFixed(2)} ${currency}`
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long ago, in the units a person would actually say it in.
|
|
20
|
+
*
|
|
21
|
+
* "37.4 hours" is a measurement; "2 days" is how somebody decides whether a
|
|
22
|
+
* cart is still worth chasing.
|
|
23
|
+
*/
|
|
24
|
+
function idle(hours: number): string {
|
|
25
|
+
if (hours < 1)
|
|
26
|
+
return 'under an hour'
|
|
27
|
+
if (hours < 48)
|
|
28
|
+
return `${Math.round(hours)}h`
|
|
29
|
+
return `${Math.round(hours / 24)} days`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contents(record: AbandonedCartRecord): string {
|
|
33
|
+
if (record.items.length === 0)
|
|
34
|
+
return `${record.itemCount} item${record.itemCount === 1 ? '' : 's'}`
|
|
35
|
+
const shown = record.items.slice(0, 2).join(', ')
|
|
36
|
+
const rest = record.itemCount - Math.min(2, record.items.length)
|
|
37
|
+
return rest > 0 ? `${shown} +${rest} more` : shown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stateLabel(record: AbandonedCartRecord): string {
|
|
41
|
+
if (record.state === 'recovered')
|
|
42
|
+
return 'Recovered'
|
|
43
|
+
if (record.state === 'expired')
|
|
44
|
+
return 'Expired'
|
|
45
|
+
return record.contacted ? 'Chased' : 'Cold'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateClass(record: AbandonedCartRecord): string {
|
|
49
|
+
if (record.state === 'recovered')
|
|
50
|
+
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
51
|
+
if (record.state === 'expired')
|
|
52
|
+
return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
|
|
53
|
+
return record.contacted
|
|
54
|
+
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
|
55
|
+
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div class="overflow-x-auto bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
60
|
+
<table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
|
|
61
|
+
<thead class="bg-gray-50 dark:bg-neutral-900/50">
|
|
62
|
+
<tr>
|
|
63
|
+
<th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Customer</th>
|
|
64
|
+
<th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Left behind</th>
|
|
65
|
+
<th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Value</th>
|
|
66
|
+
<th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Idle</th>
|
|
67
|
+
<th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">State</th>
|
|
68
|
+
</tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
71
|
+
<template :if="loading()">
|
|
72
|
+
<tr><td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading carts</td></tr>
|
|
73
|
+
</template>
|
|
74
|
+
<template :else-if="records().length === 0">
|
|
75
|
+
<tr><td colspan="5" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-shopping-cart-01"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No carts to chase</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Nothing matches these filters, which, for once, is the good outcome.</p></td></tr>
|
|
76
|
+
</template>
|
|
77
|
+
<template :else>
|
|
78
|
+
<template :for="record in records">
|
|
79
|
+
<tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-700/40">
|
|
80
|
+
<td class="px-4 py-4 max-w-xs">
|
|
81
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.customerName }}</p>
|
|
82
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.customerEmail || 'No address on the cart' }}</p>
|
|
83
|
+
</td>
|
|
84
|
+
<td class="px-4 py-4 max-w-sm">
|
|
85
|
+
<p class="text-gray-900 text-sm dark:text-white">{{ contents(record) }}</p>
|
|
86
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Cart #{{ record.id }}</p>
|
|
87
|
+
</td>
|
|
88
|
+
<td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm tabular-nums dark:text-white">{{ money(record.value, record.currency) }}</p></td>
|
|
89
|
+
<td class="px-4 py-4"><p class="text-gray-900 text-sm dark:text-white">{{ idle(record.idleHours) }}</p></td>
|
|
90
|
+
<td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + stateClass(record)">{{ stateLabel(record) }}</span></td>
|
|
91
|
+
</tr>
|
|
92
|
+
</template>
|
|
93
|
+
</template>
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
|
|
3
|
+
import { reachOf } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
|
|
4
|
+
|
|
5
|
+
const open = useReactiveProp('open', false)
|
|
6
|
+
const records = useReactiveProp('records', [] as AbandonedCartRecord[])
|
|
7
|
+
const defaultIdleHours = useReactiveProp('defaultIdleHours', 4)
|
|
8
|
+
const defaultCurrency = useReactiveProp('defaultCurrency', 'USD')
|
|
9
|
+
const busy = useReactiveProp('busy', false)
|
|
10
|
+
const error = useReactiveProp('error', '')
|
|
11
|
+
const emit = defineEmits()
|
|
12
|
+
|
|
13
|
+
const name = state('')
|
|
14
|
+
const subject = state('')
|
|
15
|
+
const text = state('')
|
|
16
|
+
const fromName = state('')
|
|
17
|
+
const fromAddress = state('')
|
|
18
|
+
const idleHours = state(4)
|
|
19
|
+
const minimumValue = state(0)
|
|
20
|
+
const scheduledAt = state('')
|
|
21
|
+
let hydrated = false
|
|
22
|
+
|
|
23
|
+
effect(() => {
|
|
24
|
+
if (!open()) {
|
|
25
|
+
hydrated = false
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (hydrated)
|
|
29
|
+
return
|
|
30
|
+
hydrated = true
|
|
31
|
+
name.set('Cart recovery')
|
|
32
|
+
subject.set('You left something behind')
|
|
33
|
+
text.set('')
|
|
34
|
+
fromName.set('')
|
|
35
|
+
fromAddress.set('')
|
|
36
|
+
idleHours.set(defaultIdleHours())
|
|
37
|
+
minimumValue.set(0)
|
|
38
|
+
scheduledAt.set('')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Who this would actually be sent to, as the rules are being set.
|
|
43
|
+
*
|
|
44
|
+
* The same function the summary is built from, run against the rows already
|
|
45
|
+
* on the page: a rule that reaches nobody should say so while somebody is
|
|
46
|
+
* still choosing it, not after they have scheduled a send to an empty room.
|
|
47
|
+
*/
|
|
48
|
+
function reach(): { carts: number, value: number } {
|
|
49
|
+
return reachOf(records(), Number(idleHours()) || 0, Number(minimumValue()) || 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function money(value: number): string {
|
|
53
|
+
try {
|
|
54
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: defaultCurrency() || 'USD' }).format(value)
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return `${value.toFixed(2)} ${defaultCurrency()}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function submit(): void {
|
|
62
|
+
const when = scheduledAt() ? new Date(scheduledAt()) : null
|
|
63
|
+
if (when && !Number.isFinite(when.getTime()))
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
emit('submit', {
|
|
67
|
+
name: name(),
|
|
68
|
+
subject: subject(),
|
|
69
|
+
text: text(),
|
|
70
|
+
fromName: fromName(),
|
|
71
|
+
fromAddress: fromAddress(),
|
|
72
|
+
idleHours: Number(idleHours()) || 0,
|
|
73
|
+
minimumValue: Number(minimumValue()) || 0,
|
|
74
|
+
scheduledAt: when ? when.toISOString() : null,
|
|
75
|
+
currency: defaultCurrency(),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<Modal :isOpen="open()" title="New recovery campaign" description="Writes to the people whose carts went cold. The audience comes from the carts themselves, not from a list." size="lg" @close="emit('close')">
|
|
81
|
+
<div class="mt-4 space-y-4">
|
|
82
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
83
|
+
<label class="block">
|
|
84
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Campaign name</span>
|
|
85
|
+
<input x-model="name" type="text" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
86
|
+
</label>
|
|
87
|
+
<label class="block">
|
|
88
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Subject line</span>
|
|
89
|
+
<input x-model="subject" type="text" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
90
|
+
</label>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
94
|
+
<label class="block">
|
|
95
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Wait before writing</span>
|
|
96
|
+
<div class="flex gap-2 items-center mt-1">
|
|
97
|
+
<input x-model="idleHours" type="number" min="1" max="336" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
98
|
+
<span class="text-gray-500 text-sm dark:text-neutral-400">hours</span>
|
|
99
|
+
</div>
|
|
100
|
+
<span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Writing to somebody who is still shopping is not a recovery, it is an interruption.</span>
|
|
101
|
+
</label>
|
|
102
|
+
<label class="block">
|
|
103
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Minimum cart value</span>
|
|
104
|
+
<input x-model="minimumValue" type="number" min="0" step="1" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
105
|
+
<span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave at zero to chase every cart.</span>
|
|
106
|
+
</label>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
110
|
+
<label class="block">
|
|
111
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">From name</span>
|
|
112
|
+
<input x-model="fromName" type="text" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
113
|
+
</label>
|
|
114
|
+
<label class="block">
|
|
115
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">From address</span>
|
|
116
|
+
<input x-model="fromAddress" type="email" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
117
|
+
</label>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<label class="block">
|
|
121
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Message</span>
|
|
122
|
+
<textarea x-model="text" rows="3" placeholder="Your cart is still here, and so is everything in it." class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"></textarea>
|
|
123
|
+
</label>
|
|
124
|
+
|
|
125
|
+
<label class="block">
|
|
126
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Send at</span>
|
|
127
|
+
<input x-model="scheduledAt" type="datetime-local" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
128
|
+
<span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave empty to save it as a draft and send it from the campaigns screen.</span>
|
|
129
|
+
</label>
|
|
130
|
+
|
|
131
|
+
<div class="p-3 bg-gray-50 dark:bg-neutral-900/50 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
132
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ reach().carts.toLocaleString() }} carts match these rules</p>
|
|
133
|
+
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ money(reach().value) }} of goods, counted from the carts on this page. Carts with no address on them cannot be written to and are not included.</p>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div :if="error()" role="alert" class="p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<template #footer>
|
|
140
|
+
<div class="flex gap-2 justify-end w-full">
|
|
141
|
+
<Button :disabled="busy()" variant="secondary" @click="emit('close')">Close</Button>
|
|
142
|
+
<Button :disabled="busy() || !name() || !subject()" :loading="busy()" @click="submit()">{{ scheduledAt() ? 'Schedule campaign' : 'Save as draft' }}</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</Modal>
|
|
@@ -443,6 +443,7 @@ export function buildNavSections(
|
|
|
443
443
|
{ to: '/marketing/lists', icon: 'list-settings', text: 'Lists' },
|
|
444
444
|
{ to: '/marketing/social-posts', icon: 'clock', text: 'Social Posts' },
|
|
445
445
|
{ to: '/marketing/campaigns', icon: 'rocket', text: 'Campaigns' },
|
|
446
|
+
{ to: '/marketing/abandoned-carts', icon: 'cart', text: 'Abandoned Carts' },
|
|
446
447
|
{ to: '/marketing/reviews', icon: 'star', text: 'Reviews' },
|
|
447
448
|
...categoryNavItems(discoveredModels, 'marketing', new Set(['campaign'])),
|
|
448
449
|
]])
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
const sending = state(false)
|
|
19
19
|
const done = state('')
|
|
20
20
|
const openedAt = Date.now()
|
|
21
|
+
// Field names with an upload in flight, so each file input can disable
|
|
22
|
+
// itself and the submit button can wait for all of them.
|
|
23
|
+
const uploading = reactive<Record<string, boolean>>({})
|
|
24
|
+
// The chosen file's name per field, so the person can see what was attached
|
|
25
|
+
// rather than only the storage path the server answered with.
|
|
26
|
+
const attached = reactive<Record<string, string>>({})
|
|
21
27
|
|
|
22
28
|
effect(() => {
|
|
23
29
|
if (!host() || definition())
|
|
@@ -52,10 +58,87 @@
|
|
|
52
58
|
return conditions.action === 'show' ? matched : !matched
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Upload one file and record the storage path it returns.
|
|
63
|
+
*
|
|
64
|
+
* The submitted value for a `file` field is that path, not the file: the
|
|
65
|
+
* server checks it sits under this form's upload prefix before accepting the
|
|
66
|
+
* submission, so a path this endpoint did not issue is rejected.
|
|
67
|
+
*
|
|
68
|
+
* Size and type are enforced server-side from `config/forms.ts`; `accept` on
|
|
69
|
+
* the input is only a picker convenience, since a file dialog filter is a
|
|
70
|
+
* suggestion and not a check.
|
|
71
|
+
*/
|
|
72
|
+
async function uploadFile(field: PublicFormField, event: Event): Promise<void> {
|
|
73
|
+
const input = event.target as HTMLInputElement
|
|
74
|
+
const file = input.files?.[0]
|
|
75
|
+
if (!file)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
uploading[field.name] = true
|
|
79
|
+
errors.set({ ...errors(), [field.name]: '' })
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const uuid = host()?.dataset.formUuid
|
|
83
|
+
const payload = new FormData()
|
|
84
|
+
payload.append('field', field.name)
|
|
85
|
+
payload.append('file', file)
|
|
86
|
+
|
|
87
|
+
const reply = await fetch(`/api/forms/${uuid}/uploads`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
credentials: 'same-origin',
|
|
90
|
+
body: payload,
|
|
91
|
+
})
|
|
92
|
+
const body = await reply.json().catch(() => ({}))
|
|
93
|
+
|
|
94
|
+
if (!reply.ok) {
|
|
95
|
+
// Clear the input as well as the value: leaving a rejected file
|
|
96
|
+
// showing next to its own error message reads as though it was taken.
|
|
97
|
+
input.value = ''
|
|
98
|
+
delete values[field.name]
|
|
99
|
+
delete attached[field.name]
|
|
100
|
+
errors.set({ ...errors(), [field.name]: body.message ?? 'That file could not be uploaded.' })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
values[field.name] = body.path
|
|
105
|
+
attached[field.name] = file.name
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
input.value = ''
|
|
109
|
+
errors.set({ ...errors(), [field.name]: 'That file could not be uploaded.' })
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
uploading[field.name] = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `accept` attribute for a field's picker.
|
|
118
|
+
*
|
|
119
|
+
* A convenience only - a file dialog filter is a suggestion, and the server
|
|
120
|
+
* enforces the real allowlist from `config/forms.ts`. Built here rather than
|
|
121
|
+
* inline in the template so the extension parameter has a type.
|
|
122
|
+
*/
|
|
123
|
+
function acceptAttr(field: PublicFormField): string {
|
|
124
|
+
return (field.options.accept ?? []).map((extension: string) => `.${extension}`).join(',')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True while any upload is still in flight. */
|
|
128
|
+
function anyUploading(): boolean {
|
|
129
|
+
return Object.values(uploading).some(Boolean)
|
|
130
|
+
}
|
|
131
|
+
|
|
55
132
|
async function submit(event: Event): Promise<void> {
|
|
56
133
|
event.preventDefault()
|
|
57
134
|
if (sending())
|
|
58
135
|
return
|
|
136
|
+
// A submission sent mid-upload would carry no path for that field and read
|
|
137
|
+
// as a missing required answer, which is not what happened.
|
|
138
|
+
if (anyUploading()) {
|
|
139
|
+
errors.set({ _form: 'Please wait for the upload to finish.' })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
59
142
|
sending.set(true)
|
|
60
143
|
errors.set({})
|
|
61
144
|
|
|
@@ -119,6 +202,14 @@
|
|
|
119
202
|
<input type="checkbox" @change="values[field.name] = $event.target.checked" class="h-4 w-4 rounded">
|
|
120
203
|
{{ field.options.placeholder ?? 'Yes' }}
|
|
121
204
|
</label>
|
|
205
|
+
{{-- A file field used to fall through to the text input below, so
|
|
206
|
+
a person typed prose into what should have been a picker and
|
|
207
|
+
the submission could never satisfy it. --}}
|
|
208
|
+
<div :else-if="field.type === 'file'" class="mt-1.5">
|
|
209
|
+
<input type="file" :required="field.required && !values[field.name]" :disabled="uploading[field.name]" accept="{{ acceptAttr(field) }}" @change="uploadFile(field, $event)" class="block w-full text-[15px] file:mr-3 file:rounded-lg file:border-0 file:bg-gray-100 file:px-3.5 file:py-2 file:text-[14px] file:font-medium">
|
|
210
|
+
<p :if="uploading[field.name]" class="mt-1.5 text-[13px] text-gray-500">Uploading...</p>
|
|
211
|
+
<p :else-if="attached[field.name]" class="mt-1.5 text-[13px] text-green-700">Attached {{ attached[field.name] }}</p>
|
|
212
|
+
</div>
|
|
122
213
|
<input :else type="{{ field.type === 'email' ? 'email' : (field.type === 'phone' ? 'tel' : (field.type === 'date' ? 'date' : (field.type === 'currency' ? 'number' : 'text'))) }}" :required="field.required" placeholder="{{ field.options.placeholder ?? '' }}" @input="values[field.name] = field.type === 'currency' ? Math.round(Number($event.target.value) * 100) : $event.target.value" class="mt-1.5 w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-[15px]">
|
|
123
214
|
<p :if="errors()[field.name]" class="mt-1.5 text-[13px] text-red-600">{{ errors()[field.name] }}</p>
|
|
124
215
|
</template>
|
|
@@ -128,7 +219,7 @@
|
|
|
128
219
|
|
|
129
220
|
<input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" class="hidden" @input="values._hp = $event.target.value">
|
|
130
221
|
|
|
131
|
-
<button type="submit" :disabled="sending()" class="mt-7 rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98] disabled:opacity-60">
|
|
222
|
+
<button type="submit" :disabled="sending() || anyUploading()" class="mt-7 rounded-lg px-6 py-3 font-medium bg-gray-900 text-white transition-transform active:scale-[0.98] disabled:opacity-60">
|
|
132
223
|
{{ sending() ? 'Sending...' : (definition()?.submitLabel ?? 'Submit') }}
|
|
133
224
|
</button>
|
|
134
225
|
</form>
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -413,6 +413,11 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
|
|
|
413
413
|
guard(route.post('/marketing/campaigns/{id}/send', 'Actions/Dashboard/Marketing/CampaignSendAction'))
|
|
414
414
|
guard(route.post('/marketing/campaigns/{id}/schedule', 'Actions/Dashboard/Marketing/CampaignScheduleAction'))
|
|
415
415
|
guard(route.post('/marketing/campaigns/{id}/cancel', 'Actions/Dashboard/Marketing/CampaignCancelAction'))
|
|
416
|
+
// Abandoned carts: the audience a shop already has, and the campaign that
|
|
417
|
+
// goes after it. The campaign it writes is an ordinary Campaign row, so it
|
|
418
|
+
// shows up on /marketing/campaigns and sends through the same pipeline.
|
|
419
|
+
guard(route.get('/marketing/abandoned-carts', 'Actions/Dashboard/Marketing/AbandonedCartIndexAction'))
|
|
420
|
+
guard(route.post('/marketing/abandoned-carts/campaign', 'Actions/Dashboard/Marketing/AbandonedCartCampaignAction'))
|
|
416
421
|
guard(route.get('/marketing/social-posts', 'Actions/Dashboard/Marketing/SocialPostIndexAction'))
|
|
417
422
|
guard(route.post('/marketing/social-posts', 'Actions/Dashboard/Marketing/SocialPostStoreAction'))
|
|
418
423
|
guard(route.patch('/marketing/social-posts/{id}', 'Actions/Dashboard/Marketing/SocialPostUpdateAction'))
|