@stacksjs/defaults 0.75.277 → 0.75.280

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.
Files changed (57) hide show
  1. package/ide/vscode/package.json +1 -1
  2. package/package.json +3 -3
  3. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
  4. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
  5. package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
  6. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
  7. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
  8. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
  9. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
  10. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
  11. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
  12. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
  13. package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
  14. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
  15. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
  16. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  17. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
  18. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
  19. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
  20. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
  21. package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
  22. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
  23. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
  24. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
  25. package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
  26. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
  27. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
  28. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
  29. package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
  30. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
  31. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
  32. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
  33. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
  34. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
  35. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
  36. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
  37. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
  38. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
  42. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
  43. package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
  44. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
  45. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
  46. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
  47. package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
  48. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
  49. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
  50. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
  51. package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
  52. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
  53. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
  54. package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
  55. package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
  56. package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
  57. 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
- /** A cart's total is integer minor units, like every commerce amount (stacksjs/stacks#2851). */
9
- function money(value: number, currency: string): string {
10
- return formatCurrency(value, currency)
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
- function stateLabel(record: AbandonedCartRecord): string {
36
- if (record.state === 'recovered')
37
- return 'Recovered'
38
- if (record.state === 'expired')
39
- return 'Expired'
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
- function stateClass(record: AbandonedCartRecord): string {
44
- if (record.state === 'recovered')
45
- return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
46
- if (record.state === 'expired')
47
- return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
48
- return record.contacted
49
- ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
50
- : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
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
- <div class="overflow-x-auto bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
55
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
56
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
57
- <tr>
58
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Customer</th>
59
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Left behind</th>
60
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Value</th>
61
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Idle</th>
62
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">State</th>
63
- </tr>
64
- </thead>
65
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
66
- <template :if="loading()">
67
- <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>
68
- </template>
69
- <template :else-if="records().length === 0">
70
- <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>
71
- </template>
72
- <template :else>
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
- function visibleRecords(): CampaignRecord[] {
109
- const start = (page() - 1) * perPage
110
- return filteredRecords().slice(start, start + perPage)
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="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" @deliver="openDelivery" />
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(new Date(value.replace(' ', 'T')))
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 requestDelivery(mode: CampaignDeliveryOperation, record: CampaignRecord): void {
37
- emit('deliver', { mode, record })
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
- <div class="overflow-x-auto bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
42
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
43
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
44
- <tr>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Campaign</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Audience</th>
47
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Timing</th>
48
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Delivery</th>
49
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
50
- <th scope="col" class="px-4 py-3"><span class="sr-only">Actions</span></th>
51
- </tr>
52
- </thead>
53
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
54
- <template :if="loading()">
55
- <tr><td colspan="6" 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 campaigns</td></tr>
56
- </template>
57
- <template :else-if="records().length === 0">
58
- <tr><td colspan="6" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-mail-send-01"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No campaigns found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or add a Campaign record.</p></td></tr>
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
- function visibleRecords(): MarketingListRecord[] {
76
- const start = (page() - 1) * perPage
77
- return filteredRecords().slice(start, start + perPage)
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="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
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
- function formatDate(value: string): string {
9
- if (!value)
10
- return 'Never'
11
- const timestamp = new Date(value.replace(' ', 'T')).getTime()
12
- return Number.isFinite(timestamp)
13
- ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(timestamp)
14
- : value
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 statusClass(status: string): string {
18
- if (status === 'active')
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
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
27
- <div :if="loading() && records().length === 0" class="flex items-center justify-center py-16 text-gray-500 text-sm dark:text-neutral-400">
28
- <span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>
29
- Loading email lists
30
- </div>
31
- <div :else-if="records().length === 0" class="px-6 py-16 text-center">
32
- <span class="inline-block h-8 w-8 text-gray-400 i-hugeicons-mail-01"></span>
33
- <p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No email lists found</p>
34
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or add an EmailList record.</p>
35
- </div>
36
- <div :else class="overflow-x-auto">
37
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
38
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
39
- <tr>
40
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">List</th>
41
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Members</th>
42
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Campaigns</th>
43
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Signup</th>
44
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Last sent</th>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs dark:text-neutral-300"><span class="sr-only">Actions</span></th>
47
- </tr>
48
- </thead>
49
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
50
- <template :for="record in records()" :key="record.id">
51
- <tr class="align-top">
52
- <td class="px-4 py-4">
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>