@stacksjs/defaults 0.75.279 → 0.75.281
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/ai/skills/stacks-database/SKILL.md +1 -3
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +3 -3
- package/project/bun-version +1 -1
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
- package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
- package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
- package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
- package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
- package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
- package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
- package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
- package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
- package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
- package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
- package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
- package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
- package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
- package/resources/components/Dashboard/UI/Table.stx +41 -2
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
|
|
3
|
-
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
4
5
|
|
|
5
6
|
const records = useReactiveProp('records', [] as AbandonedCartRecord[])
|
|
6
7
|
const loading = useReactiveProp('loading', false)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
8
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
9
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
10
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
11
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* How long ago, in the units a person would actually say it in.
|
|
@@ -32,60 +32,64 @@ function contents(record: AbandonedCartRecord): string {
|
|
|
32
32
|
return rest > 0 ? `${shown} +${rest} more` : shown
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return record.contacted ? 'Chased' : 'Cold'
|
|
35
|
+
/** Recovered and expired by state; an open cart by whether anyone has chased it yet. */
|
|
36
|
+
function stateKey(record: AbandonedCartRecord): string {
|
|
37
|
+
if (record.state === 'recovered' || record.state === 'expired')
|
|
38
|
+
return record.state
|
|
39
|
+
return record.contacted ? 'chased' : 'cold'
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
42
|
+
/**
|
|
43
|
+
* A cart's total is integer minor units, like every commerce amount
|
|
44
|
+
* (stacksjs/stacks#2851), so value is a currency column in the cart's own
|
|
45
|
+
* currency. Idle reads as words, and sorts by the hours behind them.
|
|
46
|
+
*/
|
|
47
|
+
const COLUMNS = [
|
|
48
|
+
{ key: 'customerName', label: 'Customer', sortable: true, subtitleKey: 'emailLabel', class: 'font-medium text-gray-900 dark:text-white' },
|
|
49
|
+
{ key: 'contentsLabel', label: 'Left behind', subtitleKey: 'cartLabel', class: 'text-gray-900 dark:text-white' },
|
|
50
|
+
{ key: 'value', label: 'Value', type: 'currency' as const, currencyKey: 'currency', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
|
|
51
|
+
{ key: 'idleHours', label: 'Idle', sortable: true, slotted: true },
|
|
52
|
+
{
|
|
53
|
+
key: 'stateKey',
|
|
54
|
+
label: 'State',
|
|
55
|
+
type: 'badge' as const,
|
|
56
|
+
sortable: true,
|
|
57
|
+
badgeClasses: {
|
|
58
|
+
recovered: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
|
59
|
+
expired: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300',
|
|
60
|
+
chased: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
61
|
+
cold: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
|
62
|
+
},
|
|
63
|
+
badgeLabels: { recovered: 'Recovered', expired: 'Expired', chased: 'Chased', cold: 'Cold' },
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
const rows = derived(() => records().map(record => ({
|
|
68
|
+
...record,
|
|
69
|
+
emailLabel: record.customerEmail || 'No address on the cart',
|
|
70
|
+
contentsLabel: contents(record),
|
|
71
|
+
cartLabel: `Cart #${record.id}`,
|
|
72
|
+
idleLabel: idle(record.idleHours),
|
|
73
|
+
stateKey: stateKey(record),
|
|
74
|
+
})))
|
|
52
75
|
</script>
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<template :for="record in records">
|
|
74
|
-
<tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-700/40">
|
|
75
|
-
<td class="px-4 py-4 max-w-xs">
|
|
76
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.customerName }}</p>
|
|
77
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.customerEmail || 'No address on the cart' }}</p>
|
|
78
|
-
</td>
|
|
79
|
-
<td class="px-4 py-4 max-w-sm">
|
|
80
|
-
<p class="text-gray-900 text-sm dark:text-white">{{ contents(record) }}</p>
|
|
81
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Cart #{{ record.id }}</p>
|
|
82
|
-
</td>
|
|
83
|
-
<td class="px-4 py-4"><p class="font-medium tabular-nums text-gray-900 text-sm dark:text-white">{{ money(record.value, record.currency) }}</p></td>
|
|
84
|
-
<td class="px-4 py-4"><p class="text-gray-900 text-sm dark:text-white">{{ idle(record.idleHours) }}</p></td>
|
|
85
|
-
<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>
|
|
86
|
-
</tr>
|
|
87
|
-
</template>
|
|
88
|
-
</template>
|
|
89
|
-
</tbody>
|
|
90
|
-
</table>
|
|
91
|
-
</div>
|
|
77
|
+
{{--
|
|
78
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
79
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
80
|
+
--}}
|
|
81
|
+
<Table
|
|
82
|
+
:sort-reset-key="sortResetKey()"
|
|
83
|
+
:page-reset-key="pageResetKey()"
|
|
84
|
+
:columns="COLUMNS"
|
|
85
|
+
:data="rows()"
|
|
86
|
+
:loading="loading()"
|
|
87
|
+
paginated
|
|
88
|
+
:page-size="10"
|
|
89
|
+
empty-title="No carts to chase"
|
|
90
|
+
empty-description="Nothing matches these filters, which, for once, is the good outcome."
|
|
91
|
+
>
|
|
92
|
+
<template #cell="{ row }">
|
|
93
|
+
<p class="text-gray-900 text-sm dark:text-white" x-text="row.idleLabel"></p>
|
|
94
|
+
</template>
|
|
95
|
+
</Table>
|
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
import type { CampaignDeliveryOperation } from '../../../../app/Actions/Dashboard/Marketing/campaign-delivery'
|
|
8
8
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
9
9
|
import { pushToast } from '../../../../functions/toasts'
|
|
10
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
11
10
|
|
|
12
11
|
interface CampaignIndexResponse {
|
|
13
12
|
records: CampaignRecord[]
|
|
@@ -60,8 +59,6 @@ const typeFilter = state('all')
|
|
|
60
59
|
const statusFilter = state('all')
|
|
61
60
|
const listFilter = state('all')
|
|
62
61
|
const sort = state('newest')
|
|
63
|
-
const page = state(1)
|
|
64
|
-
const perPage = 10
|
|
65
62
|
const dialogOpen = state(false)
|
|
66
63
|
const dialogMode = state<'add' | 'edit'>('add')
|
|
67
64
|
const editingRecord = state<CampaignRecord | null>(null)
|
|
@@ -105,24 +102,14 @@ function filteredRecords(): CampaignRecord[] {
|
|
|
105
102
|
})
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
/**
|
|
106
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
107
|
+
* page's own pager did before the table took over paging.
|
|
108
|
+
*/
|
|
109
|
+
function viewKey(): string {
|
|
110
|
+
return JSON.stringify([search(), typeFilter(), statusFilter(), listFilter(), sort()])
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function totalPages(): number {
|
|
114
|
-
return Math.max(1, Math.ceil(filteredRecords().length / perPage))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
effect(() => {
|
|
118
|
-
search()
|
|
119
|
-
typeFilter()
|
|
120
|
-
statusFilter()
|
|
121
|
-
listFilter()
|
|
122
|
-
sort()
|
|
123
|
-
page.set(1)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
113
|
async function loadCampaigns(): Promise<void> {
|
|
127
114
|
loading.set(true)
|
|
128
115
|
loadError.set('')
|
|
@@ -134,8 +121,6 @@ async function loadCampaigns(): Promise<void> {
|
|
|
134
121
|
summary.set(data.summary)
|
|
135
122
|
lists.set(data.lists)
|
|
136
123
|
defaultCurrency.set(data.defaultCurrency || 'USD')
|
|
137
|
-
if (page() > totalPages())
|
|
138
|
-
page.set(totalPages())
|
|
139
124
|
}
|
|
140
125
|
catch (error) {
|
|
141
126
|
loadError.set(error instanceof Error ? error.message : String(error))
|
|
@@ -272,8 +257,6 @@ async function confirmDelivery(payload: { scheduledAt?: string }): Promise<void>
|
|
|
272
257
|
}
|
|
273
258
|
}
|
|
274
259
|
|
|
275
|
-
const { previousPage, nextPage } = pageControls(page, totalPages)
|
|
276
|
-
|
|
277
260
|
onMount(() => {
|
|
278
261
|
void loadCampaigns()
|
|
279
262
|
})
|
|
@@ -305,8 +288,7 @@ onMount(() => {
|
|
|
305
288
|
<label><span class="sr-only">Filter campaign list</span><select x-model="listFilter" 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">All email lists</option><template :for="list in lists"><option :value="list.id">{{ list.name }}</option></template></select></label>
|
|
306
289
|
<label><span class="sr-only">Sort campaigns</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="newest">Newest</option><option value="name">Name</option><option value="audience">Largest audience</option><option value="sent">Most sent</option></select></label>
|
|
307
290
|
</div>
|
|
308
|
-
<CampaignsTable :records="
|
|
309
|
-
<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>
|
|
291
|
+
<CampaignsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" @deliver="openDelivery" />
|
|
310
292
|
</section>
|
|
311
293
|
</div>
|
|
312
294
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { CampaignRecord } from '../../../../app/Actions/Dashboard/Marketing/campaign-records'
|
|
3
5
|
import type { CampaignDeliveryOperation } from '../../../../app/Actions/Dashboard/Marketing/campaign-delivery'
|
|
6
|
+
import { parseDashboardDate } from '../../../../functions/dashboard-dates'
|
|
4
7
|
|
|
5
8
|
const records = useReactiveProp('records', [] as CampaignRecord[])
|
|
6
9
|
const loading = useReactiveProp('loading', false)
|
|
10
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
11
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
12
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
13
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
7
14
|
const emit = defineEmits()
|
|
8
15
|
|
|
9
16
|
function titleCase(value: string): string {
|
|
@@ -16,7 +23,7 @@ function formatDate(value: string): string {
|
|
|
16
23
|
return new Intl.DateTimeFormat(undefined, {
|
|
17
24
|
dateStyle: 'medium',
|
|
18
25
|
timeStyle: 'short',
|
|
19
|
-
}).format(
|
|
26
|
+
}).format(parseDashboardDate(value))
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
function percentage(value: number): string {
|
|
@@ -33,62 +40,78 @@ function canCancel(record: CampaignRecord): boolean {
|
|
|
33
40
|
&& ['scheduled', 'sending'].includes(record.status)
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
function
|
|
37
|
-
|
|
43
|
+
function timingNote(record: CampaignRecord): string {
|
|
44
|
+
if (record.sentAt)
|
|
45
|
+
return 'Sent'
|
|
46
|
+
if (record.status === 'scheduled')
|
|
47
|
+
return 'Scheduled'
|
|
48
|
+
if (record.startDate)
|
|
49
|
+
return 'Starts'
|
|
50
|
+
return record.scheduledAt ? 'Originally scheduled' : 'No timing'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const COLUMNS = [
|
|
54
|
+
{ key: 'name', label: 'Campaign', sortable: true, subtitleKey: 'kindLabel', class: 'font-medium text-gray-900 dark:text-white' },
|
|
55
|
+
{ key: 'emailListName', label: 'Audience', sortable: true, subtitleKey: 'audienceLabel', class: 'text-gray-900 dark:text-white' },
|
|
56
|
+
{ key: 'timingLabel', label: 'Timing', subtitleKey: 'timingNote', class: 'text-gray-900 dark:text-white' },
|
|
57
|
+
{ key: 'sentLabel', label: 'Delivery', subtitleKey: 'ratesLabel', class: 'text-gray-900 dark:text-white' },
|
|
58
|
+
{ key: 'status', label: 'Status', type: 'badge' as const, sortable: true, badgeLabelKey: 'statusLabel' },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delivery actions only where they apply: send and schedule to an email
|
|
63
|
+
* campaign that has not gone out, cancel while one is queued or sending.
|
|
64
|
+
* Edit and delete leave a campaign that is going or gone, which the
|
|
65
|
+
* hand-rolled buttons showed disabled.
|
|
66
|
+
*/
|
|
67
|
+
const ROW_ACTIONS = [
|
|
68
|
+
{ action: 'send', label: 'Send now', icon: 'i-hugeicons-mail-send-02', when: { key: 'sendable', equals: true } },
|
|
69
|
+
{ action: 'schedule', label: 'Schedule', icon: 'i-hugeicons-calendar-03', when: { key: 'scheduleLabel', equals: 'schedule' } },
|
|
70
|
+
{ action: 'schedule', label: 'Reschedule', icon: 'i-hugeicons-calendar-03', when: { key: 'scheduleLabel', equals: 'reschedule' } },
|
|
71
|
+
{ action: 'cancel', label: 'Cancel delivery of', icon: 'i-hugeicons-cancel-circle', class: 'text-red-500', when: { key: 'cancellable', equals: true } },
|
|
72
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02', when: { key: 'editable', equals: true } },
|
|
73
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500', when: { key: 'deletable', equals: true } },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const rows = derived(() => records().map(record => ({
|
|
77
|
+
...record,
|
|
78
|
+
kindLabel: record.subject ? `${titleCase(record.type)} · ${record.subject}` : titleCase(record.type),
|
|
79
|
+
audienceLabel: `${record.audienceSize.toLocaleString()} available`,
|
|
80
|
+
timingLabel: formatDate(record.sentAt || record.scheduledAt || record.startDate),
|
|
81
|
+
timingNote: timingNote(record),
|
|
82
|
+
sentLabel: `${record.sentCount.toLocaleString()} sent`,
|
|
83
|
+
ratesLabel: `${percentage(record.openRate)} open · ${percentage(record.clickRate)} click`,
|
|
84
|
+
statusLabel: titleCase(record.status),
|
|
85
|
+
sendable: canSend(record),
|
|
86
|
+
scheduleLabel: canSend(record) ? (record.status === 'scheduled' ? 'reschedule' : 'schedule') : 'none',
|
|
87
|
+
cancellable: canCancel(record),
|
|
88
|
+
editable: !['sending', 'sent'].includes(record.status),
|
|
89
|
+
deletable: !['scheduled', 'sending', 'sent'].includes(record.status),
|
|
90
|
+
})))
|
|
91
|
+
|
|
92
|
+
function handleRowAction(payload: { action: string, row: CampaignRecord }): void {
|
|
93
|
+
if (payload.action === 'edit' || payload.action === 'delete')
|
|
94
|
+
emit(payload.action, payload.row)
|
|
95
|
+
else
|
|
96
|
+
emit('deliver', { mode: payload.action as CampaignDeliveryOperation, record: payload.row })
|
|
38
97
|
}
|
|
39
98
|
</script>
|
|
40
99
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</template>
|
|
60
|
-
<template :else>
|
|
61
|
-
<template :for="record in records">
|
|
62
|
-
<tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-700/40">
|
|
63
|
-
<td class="px-4 py-4 max-w-sm">
|
|
64
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
|
|
65
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ titleCase(record.type) }}<span :if="record.subject"> · {{ record.subject }}</span></p>
|
|
66
|
-
</td>
|
|
67
|
-
<td class="px-4 py-4">
|
|
68
|
-
<p class="text-gray-900 text-sm dark:text-white">{{ record.emailListName }}</p>
|
|
69
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.audienceSize.toLocaleString() }} available</p>
|
|
70
|
-
</td>
|
|
71
|
-
<td class="px-4 py-4">
|
|
72
|
-
<p class="text-gray-900 text-sm dark:text-white">{{ formatDate(record.sentAt || record.scheduledAt || record.startDate) }}</p>
|
|
73
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.sentAt ? 'Sent' : record.status === 'scheduled' ? 'Scheduled' : record.startDate ? 'Starts' : record.scheduledAt ? 'Originally scheduled' : 'No timing' }}</p>
|
|
74
|
-
</td>
|
|
75
|
-
<td class="px-4 py-4">
|
|
76
|
-
<p class="text-gray-900 text-sm dark:text-white">{{ record.sentCount.toLocaleString() }} sent</p>
|
|
77
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ percentage(record.openRate) }} open · {{ percentage(record.clickRate) }} click</p>
|
|
78
|
-
</td>
|
|
79
|
-
<td class="px-4 py-4"><span class="inline-flex px-2 py-1 font-medium text-xs bg-gray-100 dark:bg-neutral-700 rounded-full">{{ titleCase(record.status) }}</span></td>
|
|
80
|
-
<td class="px-4 py-4">
|
|
81
|
-
<div class="flex gap-1 justify-end">
|
|
82
|
-
<Button :if="canSend(record)" variant="ghost" size="sm" iconOnly :ariaLabel="'Send campaign ' + record.id + ' now'" @click="requestDelivery('send', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-mail-send-02"></span></Button>
|
|
83
|
-
<Button :if="canSend(record)" variant="ghost" size="sm" iconOnly :ariaLabel="(record.status === 'scheduled' ? 'Reschedule campaign ' : 'Schedule campaign ') + record.id" @click="requestDelivery('schedule', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-calendar-03"></span></Button>
|
|
84
|
-
<Button :if="canCancel(record)" variant="ghost" size="sm" iconOnly :ariaLabel="'Cancel campaign ' + record.id + ' delivery'" @click="requestDelivery('cancel', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-cancel-circle"></span></Button>
|
|
85
|
-
<Button variant="ghost" size="sm" iconOnly :disabled="['sending', 'sent'].includes(record.status)" :ariaLabel="'Edit campaign ' + record.id" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-02"></span></Button>
|
|
86
|
-
<Button variant="ghost" size="sm" iconOnly :disabled="['scheduled', 'sending', 'sent'].includes(record.status)" :ariaLabel="'Delete campaign ' + record.id" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button>
|
|
87
|
-
</div>
|
|
88
|
-
</td>
|
|
89
|
-
</tr>
|
|
90
|
-
</template>
|
|
91
|
-
</template>
|
|
92
|
-
</tbody>
|
|
93
|
-
</table>
|
|
94
|
-
</div>
|
|
100
|
+
{{--
|
|
101
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
102
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
103
|
+
--}}
|
|
104
|
+
<Table
|
|
105
|
+
:sort-reset-key="sortResetKey()"
|
|
106
|
+
:page-reset-key="pageResetKey()"
|
|
107
|
+
:columns="COLUMNS"
|
|
108
|
+
:data="rows()"
|
|
109
|
+
:loading="loading()"
|
|
110
|
+
:row-actions="ROW_ACTIONS"
|
|
111
|
+
action-label-key="name"
|
|
112
|
+
paginated
|
|
113
|
+
:page-size="10"
|
|
114
|
+
empty-title="No campaigns found"
|
|
115
|
+
empty-description="Adjust the filters or add a Campaign record."
|
|
116
|
+
@row-action="handleRowAction"
|
|
117
|
+
/>
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
} from '../../../../app/Actions/Dashboard/Marketing/marketing-list-records'
|
|
6
6
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
7
7
|
import { pushToast } from '../../../../functions/toasts'
|
|
8
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
9
8
|
|
|
10
9
|
interface MarketingListIndexResponse {
|
|
11
10
|
records: MarketingListRecord[]
|
|
@@ -37,8 +36,6 @@ const search = state('')
|
|
|
37
36
|
const statusFilter = state('all')
|
|
38
37
|
const visibilityFilter = state('all')
|
|
39
38
|
const sort = state('name')
|
|
40
|
-
const page = state(1)
|
|
41
|
-
const perPage = 10
|
|
42
39
|
const dialogOpen = state(false)
|
|
43
40
|
const dialogMode = state<'add' | 'edit'>('add')
|
|
44
41
|
const editingRecord = state<MarketingListRecord | null>(null)
|
|
@@ -72,23 +69,14 @@ function filteredRecords(): MarketingListRecord[] {
|
|
|
72
69
|
})
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
/**
|
|
73
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
74
|
+
* page's own pager did before the table took over paging.
|
|
75
|
+
*/
|
|
76
|
+
function viewKey(): string {
|
|
77
|
+
return JSON.stringify([search(), statusFilter(), visibilityFilter(), sort()])
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function totalPages(): number {
|
|
81
|
-
return Math.max(1, Math.ceil(filteredRecords().length / perPage))
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
effect(() => {
|
|
85
|
-
search()
|
|
86
|
-
statusFilter()
|
|
87
|
-
visibilityFilter()
|
|
88
|
-
sort()
|
|
89
|
-
page.set(1)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
80
|
async function loadLists(): Promise<void> {
|
|
93
81
|
loading.set(true)
|
|
94
82
|
loadError.set('')
|
|
@@ -98,8 +86,6 @@ async function loadLists(): Promise<void> {
|
|
|
98
86
|
throw new TypeError('Email list resources were not returned in the expected shape.')
|
|
99
87
|
records.set(data.records)
|
|
100
88
|
summary.set(data.summary)
|
|
101
|
-
if (page() > totalPages())
|
|
102
|
-
page.set(totalPages())
|
|
103
89
|
}
|
|
104
90
|
catch (error) {
|
|
105
91
|
loadError.set(error instanceof Error ? error.message : String(error))
|
|
@@ -194,8 +180,6 @@ async function deleteList(): Promise<void> {
|
|
|
194
180
|
}
|
|
195
181
|
}
|
|
196
182
|
|
|
197
|
-
const { previousPage, nextPage } = pageControls(page, totalPages)
|
|
198
|
-
|
|
199
183
|
onMount(() => {
|
|
200
184
|
void loadLists()
|
|
201
185
|
})
|
|
@@ -235,11 +219,7 @@ onMount(() => {
|
|
|
235
219
|
<label><span class="sr-only">Filter visibility</span><select x-model="visibilityFilter" 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">All signup modes</option><option value="public">Public</option><option value="private">Private</option></select></label>
|
|
236
220
|
<label><span class="sr-only">Sort email lists</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="name">Name</option><option value="members">Most members</option><option value="campaigns">Most campaigns</option><option value="newest">Newest</option></select></label>
|
|
237
221
|
</div>
|
|
238
|
-
<MarketingListsTable :records="
|
|
239
|
-
<div :if="filteredRecords().length > perPage" class="flex items-center justify-between">
|
|
240
|
-
<p class="text-gray-500 text-sm dark:text-neutral-400">Page {{ page() }} of {{ totalPages() }}</p>
|
|
241
|
-
<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>
|
|
242
|
-
</div>
|
|
222
|
+
<MarketingListsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
|
|
243
223
|
</section>
|
|
244
224
|
</div>
|
|
245
225
|
|
|
@@ -1,82 +1,77 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { MarketingListRecord } from '../../../../app/Actions/Dashboard/Marketing/marketing-list-records'
|
|
3
5
|
|
|
4
6
|
const records = useReactiveProp('records', [] as MarketingListRecord[])
|
|
5
7
|
const loading = useReactiveProp('loading', false)
|
|
8
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
9
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
10
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
11
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
6
12
|
const emit = defineEmits()
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** The list cell is slotted for the drift warning beside the name. */
|
|
15
|
+
const COLUMNS = [
|
|
16
|
+
{ key: 'name', label: 'List', sortable: true, slotted: true },
|
|
17
|
+
{ key: 'subscriberCount', label: 'Members', type: 'number' as const, sortable: true, subtitleKey: 'membersLabel', class: 'font-medium tabular-nums text-gray-900 dark:text-white' },
|
|
18
|
+
{ key: 'campaignCount', label: 'Campaigns', type: 'number' as const, sortable: true, class: 'font-medium tabular-nums' },
|
|
19
|
+
{ key: 'visibilityLabel', label: 'Signup', sortable: true, subtitleKey: 'optInLabel' },
|
|
20
|
+
{ key: 'lastSentAt', label: 'Last sent', type: 'date' as const, sortable: true, emptyText: 'Never', class: 'whitespace-nowrap' },
|
|
21
|
+
{
|
|
22
|
+
key: 'status',
|
|
23
|
+
label: 'Status',
|
|
24
|
+
type: 'badge' as const,
|
|
25
|
+
sortable: true,
|
|
26
|
+
badgeClasses: {
|
|
27
|
+
active: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
|
|
28
|
+
archived: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300',
|
|
29
|
+
inactive: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const ROW_ACTIONS = [
|
|
35
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
|
|
36
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const rows = derived(() => records().map(record => ({
|
|
40
|
+
...record,
|
|
41
|
+
membersLabel: `${record.activeCount} active, ${record.unsubscribedCount} unsubscribed`,
|
|
42
|
+
visibilityLabel: record.isPublic ? 'Public' : 'Private',
|
|
43
|
+
optInLabel: record.doubleOptIn ? 'Double opt-in' : 'Single opt-in',
|
|
44
|
+
})))
|
|
16
45
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
|
20
|
-
if (status === 'archived')
|
|
21
|
-
return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
|
|
22
|
-
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
|
|
46
|
+
function handleRowAction(payload: { action: string, row: MarketingListRecord }): void {
|
|
47
|
+
emit(payload.action, payload.row)
|
|
23
48
|
}
|
|
24
49
|
</script>
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<div class="flex gap-2 items-center">
|
|
54
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
|
|
55
|
-
<span :if="record.countDrift" x-tooltip="Stored counters differ from membership rows" title="Stored counters differ from membership rows" class="h-4 w-4 text-amber-500 i-hugeicons-alert-02"></span>
|
|
56
|
-
</div>
|
|
57
|
-
<p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ record.slug }}</p>
|
|
58
|
-
<p :if="record.description" class="mt-1 max-w-sm text-gray-500 text-xs dark:text-neutral-400">{{ record.description }}</p>
|
|
59
|
-
</td>
|
|
60
|
-
<td class="px-4 py-4">
|
|
61
|
-
<p class="font-medium tabular-nums text-gray-900 text-sm dark:text-white">{{ record.subscriberCount.toLocaleString() }}</p>
|
|
62
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.activeCount }} active, {{ record.unsubscribedCount }} unsubscribed</p>
|
|
63
|
-
</td>
|
|
64
|
-
<td class="px-4 py-4 font-medium tabular-nums text-gray-700 text-sm dark:text-neutral-200">{{ record.campaignCount }}</td>
|
|
65
|
-
<td class="px-4 py-4 text-gray-600 text-sm dark:text-neutral-300">
|
|
66
|
-
<p>{{ record.isPublic ? 'Public' : 'Private' }}</p>
|
|
67
|
-
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.doubleOptIn ? 'Double opt-in' : 'Single opt-in' }}</p>
|
|
68
|
-
</td>
|
|
69
|
-
<td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.lastSentAt) }}</td>
|
|
70
|
-
<td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record.status)">{{ record.status }}</span></td>
|
|
71
|
-
<td class="px-4 py-4">
|
|
72
|
-
<div class="flex gap-1 justify-end">
|
|
73
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + record.name" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button>
|
|
74
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.name" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button>
|
|
75
|
-
</div>
|
|
76
|
-
</td>
|
|
77
|
-
</tr>
|
|
78
|
-
</template>
|
|
79
|
-
</tbody>
|
|
80
|
-
</table>
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
51
|
+
{{--
|
|
52
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
53
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
54
|
+
--}}
|
|
55
|
+
<Table
|
|
56
|
+
:sort-reset-key="sortResetKey()"
|
|
57
|
+
:page-reset-key="pageResetKey()"
|
|
58
|
+
:columns="COLUMNS"
|
|
59
|
+
:data="rows()"
|
|
60
|
+
:loading="loading()"
|
|
61
|
+
:row-actions="ROW_ACTIONS"
|
|
62
|
+
action-label-key="name"
|
|
63
|
+
paginated
|
|
64
|
+
:page-size="10"
|
|
65
|
+
empty-title="No email lists found"
|
|
66
|
+
empty-description="Adjust the filters or add an EmailList record."
|
|
67
|
+
@row-action="handleRowAction"
|
|
68
|
+
>
|
|
69
|
+
<template #cell="{ row }">
|
|
70
|
+
<div class="flex gap-2 items-center">
|
|
71
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.name"></p>
|
|
72
|
+
<span :if="row.countDrift" x-tooltip="Stored counters differ from membership rows" title="Stored counters differ from membership rows" class="h-4 w-4 text-amber-500 i-hugeicons-alert-02"></span>
|
|
73
|
+
</div>
|
|
74
|
+
<p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400" x-text="row.slug"></p>
|
|
75
|
+
<p :if="row.description" class="mt-1 max-w-sm text-gray-500 text-xs dark:text-neutral-400" x-text="row.description"></p>
|
|
76
|
+
</template>
|
|
77
|
+
</Table>
|