@stacksjs/defaults 0.75.279 → 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 +2 -2
  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,3 +1,5 @@
1
+ @import('../../UI/Table')
2
+
1
3
  <script client>
2
4
  type SerializedList = string | string[] | null | undefined
3
5
 
@@ -13,6 +15,9 @@ interface ShippingZoneRow {
13
15
  }
14
16
 
15
17
  const zones = useReactiveProp('zones', [] as ShippingZoneRow[])
18
+ const loading = useReactiveProp('loading', false)
19
+ /** The parent's search: a change returns to the first page. */
20
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
16
21
  const emit = defineEmits()
17
22
 
18
23
  function parseList(value: SerializedList): string[] {
@@ -33,119 +38,89 @@ function parseList(value: SerializedList): string[] {
33
38
  return []
34
39
  }
35
40
 
36
- function visibleItems(value: SerializedList): string[] {
37
- return parseList(value).slice(0, 3)
38
- }
41
+ /** Countries and regions are slotted for their chips: the first three, then a count. */
42
+ const COLUMNS = [
43
+ { key: 'name', label: 'Zone', sortable: true, class: 'font-medium text-stone-800 whitespace-nowrap dark:text-white' },
44
+ { key: 'methodLabel', label: 'Method', sortable: true, class: 'whitespace-nowrap' },
45
+ { key: 'countriesText', label: 'Countries', slotted: true },
46
+ { key: 'regionsText', label: 'Regions', slotted: true },
47
+ { key: 'postalLabel', label: 'Postal codes', class: 'max-w-xs truncate' },
48
+ {
49
+ key: 'status',
50
+ label: 'Status',
51
+ type: 'badge' as const,
52
+ sortable: true,
53
+ badgeClasses: {
54
+ active: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300',
55
+ draft: 'bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300',
56
+ },
57
+ },
58
+ ]
39
59
 
40
- function remainingItems(value: SerializedList): number {
41
- return Math.max(0, parseList(value).length - 3)
42
- }
43
-
44
- function formatPostalCodes(value: SerializedList): string {
45
- const postalCodes = parseList(value)
46
- return postalCodes.length ? postalCodes.join(', ') : 'All postal codes'
47
- }
60
+ const ROW_ACTIONS = [
61
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
62
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
63
+ ]
48
64
 
49
- function statusClass(status: string): string {
50
- if (status === 'active')
51
- return 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-300 dark:ring-emerald-400/20'
52
- if (status === 'draft')
53
- return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/20'
54
-
55
- return 'bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-white/5 dark:text-neutral-300 dark:ring-white/10'
56
- }
65
+ const rows = derived(() => zones().map((zone) => {
66
+ const countries = parseList(zone.countries)
67
+ const regions = parseList(zone.regions)
68
+ const postal = parseList(zone.postal_codes)
69
+ return {
70
+ ...zone,
71
+ methodLabel: zone.shipping_method?.name || `Method #${zone.shipping_method_id}`,
72
+ countryChips: countries.slice(0, 3),
73
+ countriesMore: countries.length > 3 ? `+${countries.length - 3}` : '',
74
+ countriesText: countries.join(', '),
75
+ regionChips: regions.slice(0, 3),
76
+ regionsMore: regions.length > 3 ? `+${regions.length - 3}` : '',
77
+ regionsText: regions.join(', '),
78
+ postalLabel: postal.length ? postal.join(', ') : 'All postal codes',
79
+ actionName: `${zone.name}, record ${zone.id}`,
80
+ }
81
+ }))
57
82
 
58
- function formatStatus(status: string): string {
59
- return status.charAt(0).toUpperCase() + status.slice(1)
83
+ function handleRowAction(payload: { action: string, row: ShippingZoneRow }): void {
84
+ emit(payload.action, payload.row)
60
85
  }
61
86
  </script>
62
87
 
63
- <div class="overflow-x-auto border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
64
- <table class="min-w-full divide-stone-100 divide-y dark:divide-neutral-700">
65
- <thead class="bg-stone-50/80 dark:bg-neutral-800">
66
- <tr>
67
- <th scope="col" class="pl-6 pr-3 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Zone Name</th>
68
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Method</th>
69
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Countries</th>
70
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Regions</th>
71
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Postal Codes</th>
72
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
73
- <th scope="col" class="relative pl-3 pr-6 py-3.5">
74
- <span class="sr-only">Actions</span>
75
- </th>
76
- </tr>
77
- </thead>
78
- <tbody class="bg-white dark:bg-neutral-800 divide-stone-100 divide-y dark:divide-neutral-700">
79
- <template :for="zone in zones" :key="zone.id">
80
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-700/50 transition-colors">
81
- <td class="pl-6 pr-3 py-3.5 font-medium text-sm text-stone-800 whitespace-nowrap dark:text-white">
82
- {{ zone.name }}
83
- </td>
84
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
85
- {{ zone.shipping_method?.name || 'Method #' + zone.shipping_method_id }}
86
- </td>
87
- <td class="px-4 py-3.5 text-sm text-stone-500 dark:text-neutral-300">
88
- <div class="flex flex-wrap gap-1">
89
- <template :for="country in visibleItems(zone.countries)">
90
- <span class="inline-flex px-2 py-1 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-400/10 rounded-full">
91
- {{ country }}
92
- </span>
93
- </template>
94
- <span :if="remainingItems(zone.countries) > 0" class="inline-flex px-2 py-1 font-medium text-stone-600 text-xs dark:text-neutral-300 bg-stone-100 dark:bg-white/5 rounded-full">
95
- +{{ remainingItems(zone.countries) }}
96
- </span>
97
- <span :if="parseList(zone.countries).length === 0">All countries</span>
98
- </div>
99
- </td>
100
- <td class="px-4 py-3.5 text-sm text-stone-500 dark:text-neutral-300">
101
- <div class="flex flex-wrap gap-1">
102
- <template :for="region in visibleItems(zone.regions)">
103
- <span class="inline-flex px-2 py-1 font-medium text-emerald-700 text-xs dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-400/10 rounded-full">
104
- {{ region }}
105
- </span>
106
- </template>
107
- <span :if="remainingItems(zone.regions) > 0" class="inline-flex px-2 py-1 font-medium text-stone-600 text-xs dark:text-neutral-300 bg-stone-100 dark:bg-white/5 rounded-full">
108
- +{{ remainingItems(zone.regions) }}
109
- </span>
110
- <span :if="parseList(zone.regions).length === 0">All regions</span>
111
- </div>
112
- </td>
113
- <td class="px-4 py-3.5 max-w-xs text-sm text-stone-500 truncate dark:text-neutral-300">
114
- {{ formatPostalCodes(zone.postal_codes) }}
115
- </td>
116
- <td class="px-4 py-3.5 text-sm whitespace-nowrap">
117
- <span :class="'inline-flex px-2.5 py-1 font-medium text-xs ring-1 ring-inset rounded-full ' + statusClass(zone.status)">
118
- {{ formatStatus(zone.status) }}
119
- </span>
120
- </td>
121
- <td class="relative pl-3 pr-6 py-3.5 font-medium text-right text-sm whitespace-nowrap">
122
- <Button
123
- variant="ghost"
124
- size="sm"
125
- iconOnly
126
- @click="emit('edit', zone)"
127
- :ariaLabel="'Edit ' + zone.name + ', record ' + zone.id"
128
- >
129
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
130
- </Button>
131
- <Button
132
- variant="ghost"
133
- size="sm"
134
- iconOnly
135
- @click="emit('delete', zone)"
136
- :ariaLabel="'Delete ' + zone.name + ', record ' + zone.id"
137
- >
138
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
139
- </Button>
140
- </td>
141
- </tr>
142
- </template>
143
-
144
- <tr :if="zones().length === 0">
145
- <td colspan="7" class="px-6 py-12 text-center text-sm text-stone-500 dark:text-neutral-400">
146
- No shipping zones found.
147
- </td>
148
- </tr>
149
- </tbody>
150
- </table>
151
- </div>
88
+ {{--
89
+ No `searchable` or `filterable`: the parent dashboard owns the search box.
90
+ Table pages the whole filtered list the parent passes, and stays mounted
91
+ while it loads.
92
+ --}}
93
+ <Table
94
+ :page-reset-key="pageResetKey()"
95
+ :columns="COLUMNS"
96
+ :data="rows()"
97
+ :loading="loading()"
98
+ :row-actions="ROW_ACTIONS"
99
+ action-label-key="actionName"
100
+ paginated
101
+ :page-size="10"
102
+ empty-title="No shipping zones found"
103
+ empty-description="Add a zone, or adjust the search."
104
+ @row-action="handleRowAction"
105
+ >
106
+ <template #cell="{ row, column }">
107
+ <template :if="column.key === 'countriesText'">
108
+ <div class="flex flex-wrap gap-1">
109
+ <template :for="country in row.countryChips">
110
+ <span class="inline-flex px-2 py-1 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-400/10 rounded-full" x-text="country"></span>
111
+ </template>
112
+ <span :if="row.countriesMore" class="inline-flex px-2 py-1 font-medium text-stone-600 text-xs dark:text-neutral-300 bg-stone-100 dark:bg-white/5 rounded-full" x-text="row.countriesMore"></span>
113
+ <span :if="row.countryChips.length === 0">All countries</span>
114
+ </div>
115
+ </template>
116
+ <template :if="column.key === 'regionsText'">
117
+ <div class="flex flex-wrap gap-1">
118
+ <template :for="region in row.regionChips">
119
+ <span class="inline-flex px-2 py-1 font-medium text-emerald-700 text-xs dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-400/10 rounded-full" x-text="region"></span>
120
+ </template>
121
+ <span :if="row.regionsMore" class="inline-flex px-2 py-1 font-medium text-stone-600 text-xs dark:text-neutral-300 bg-stone-100 dark:bg-white/5 rounded-full" x-text="row.regionsMore"></span>
122
+ <span :if="row.regionChips.length === 0">All regions</span>
123
+ </div>
124
+ </template>
125
+ </template>
126
+ </Table>
@@ -6,7 +6,6 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Commerce/print-device-records'
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
- import { pageControls } from '../../../../functions/dashboard-list'
10
9
 
11
10
  interface PrintDevicesResponse {
12
11
  records: PrintDeviceRecord[]
@@ -35,8 +34,6 @@ const search = state('')
35
34
  const statusFilter = state('all')
36
35
  const locationFilter = state('all')
37
36
  const sort = state('name')
38
- const page = state(1)
39
- const pageSize = 20
40
37
  const loading = state(true)
41
38
  const loadError = state('')
42
39
  const dialogOpen = state(false)
@@ -76,15 +73,6 @@ function filteredRecords(): PrintDeviceRecord[] {
76
73
  })
77
74
  }
78
75
 
79
- function pageCount(): number {
80
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
81
- }
82
-
83
- function visibleRecords(): PrintDeviceRecord[] {
84
- const selectedPage = Math.min(page(), pageCount())
85
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
86
- }
87
-
88
76
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
89
77
  const current = summary()
90
78
  const health = current.total ? Math.round((current.online / current.total) * 100) : 0
@@ -118,13 +106,13 @@ function exportFilename(): string {
118
106
  return `print-devices-${new Date().toISOString().slice(0, 10)}.csv`
119
107
  }
120
108
 
121
- effect(() => {
122
- search()
123
- statusFilter()
124
- locationFilter()
125
- sort()
126
- page.set(1)
127
- })
109
+ /**
110
+ * What the table is showing: a change returns it to the first page, as this
111
+ * page's own pager did before the table took over paging.
112
+ */
113
+ function viewKey(): string {
114
+ return JSON.stringify([search(), statusFilter(), locationFilter(), sort()])
115
+ }
128
116
 
129
117
  async function loadPrintDevices(): Promise<void> {
130
118
  loading.set(true)
@@ -133,7 +121,6 @@ async function loadPrintDevices(): Promise<void> {
133
121
  const data = await dashboardApi<PrintDevicesResponse>('/api/dashboard/commerce/print-devices')
134
122
  records.set(Array.isArray(data.records) ? data.records : [])
135
123
  summary.set(data.summary ?? { ...emptySummary })
136
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
137
124
  }
138
125
  catch (error) {
139
126
  loadError.set('Print devices could not be loaded.')
@@ -235,8 +222,6 @@ async function deletePrintDevice(): Promise<void> {
235
222
  }
236
223
  }
237
224
 
238
- const { previousPage, nextPage } = pageControls(page, pageCount)
239
-
240
225
  onMount(() => {
241
226
  void loadPrintDevices()
242
227
  })
@@ -271,14 +256,9 @@ onMount(() => {
271
256
 
272
257
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
273
258
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ filteredRecords().length }} matching print devices</p>
274
- <div class="flex gap-2 items-center">
275
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
276
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
277
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
278
- </div>
279
259
  </div>
280
260
 
281
- <PrintDevicesTable :records="visibleRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
261
+ <PrintDevicesTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
282
262
  </section>
283
263
  </div>
284
264
 
@@ -1,22 +1,16 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { PrintDeviceRecord } from '../../../../app/Actions/Dashboard/Commerce/print-device-records'
3
5
 
4
6
  const records = useReactiveProp('records', [] as PrintDeviceRecord[])
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 statusClass(status: string): string {
9
- if (status === 'online')
10
- return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300'
11
- if (status === 'warning')
12
- return 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300'
13
- return 'bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300'
14
- }
15
-
16
- function statusLabel(status: string): string {
17
- return status.charAt(0).toUpperCase() + status.slice(1)
18
- }
19
-
20
14
  function formatHeartbeat(value: number): string {
21
15
  if (!value)
22
16
  return 'Not recorded'
@@ -34,50 +28,61 @@ function formatHeartbeat(value: number): string {
34
28
  return `${Math.abs(hours)} hr ${hours > 0 ? 'ago' : 'from now'}`
35
29
  return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
36
30
  }
31
+
32
+ /** The heartbeat reads relative to now, so it is text rather than a date column. */
33
+ const COLUMNS = [
34
+ { key: 'nameLabel', label: 'Printer', sortable: true, subtitleKey: 'macLabel', class: 'font-medium text-gray-900 dark:text-white' },
35
+ { key: 'location', label: 'Location', sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
36
+ { key: 'terminal', label: 'Terminal', sortable: true, emptyText: 'Not recorded', class: 'font-mono whitespace-nowrap' },
37
+ {
38
+ key: 'status',
39
+ label: 'Status',
40
+ type: 'badge' as const,
41
+ sortable: true,
42
+ badgeClasses: {
43
+ online: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300',
44
+ warning: 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
45
+ offline: 'bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300',
46
+ },
47
+ badgeLabels: { online: 'Online', warning: 'Warning', offline: 'Offline' },
48
+ },
49
+ { key: 'heartbeatLabel', label: 'Last heartbeat', searchable: false, class: 'whitespace-nowrap' },
50
+ { key: 'printCount', label: 'Prints', type: 'number' as const, sortable: true, align: 'right' as const, class: 'font-mono' },
51
+ ]
52
+
53
+ const ROW_ACTIONS = [
54
+ { action: 'view', label: 'View', icon: 'i-hugeicons-view' },
55
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
56
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
57
+ ]
58
+
59
+ const rows = derived(() => records().map(record => ({
60
+ ...record,
61
+ nameLabel: record.name || 'Unnamed printer',
62
+ macLabel: record.macAddress || 'No MAC address',
63
+ heartbeatLabel: formatHeartbeat(record.lastPing),
64
+ })))
65
+
66
+ function handleRowAction(payload: { action: string, row: PrintDeviceRecord }): void {
67
+ emit(payload.action, payload.row)
68
+ }
37
69
  </script>
38
70
 
39
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
40
- <div :if="loading() && records().length === 0" class="flex items-center justify-center p-10 text-gray-500 text-sm dark:text-neutral-400">
41
- <span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
42
- Loading print devices
43
- </div>
44
- <div :else-if="records().length === 0" class="p-10 text-center">
45
- <p class="font-medium text-gray-900 text-sm dark:text-white">No print devices found</p>
46
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Add a printer or adjust the current filters.</p>
47
- </div>
48
- <div :else class="overflow-x-auto">
49
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
50
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
51
- <tr>
52
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Printer</th>
53
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Location</th>
54
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Terminal</th>
55
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Status</th>
56
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Last heartbeat</th>
57
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs uppercase dark:text-neutral-400">Prints</th>
58
- <th scope="col" class="relative px-4 py-3"><span class="sr-only">Actions</span></th>
59
- </tr>
60
- </thead>
61
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
62
- <template :for="record in records()" :key="record.id">
63
- <tr class="hover:bg-gray-50/60 dark:hover:bg-neutral-700/40">
64
- <td class="px-4 py-4 min-w-56">
65
- <Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.name || 'Unnamed printer' }}</Button>
66
- <p class="mt-1 font-mono text-gray-400 text-xs dark:text-neutral-500">{{ record.macAddress || 'No MAC address' }}</p>
67
- </td>
68
- <td class="px-4 py-4 text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200">{{ record.location || 'Not recorded' }}</td>
69
- <td class="px-4 py-4 font-mono text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ record.terminal || 'Not recorded' }}</td>
70
- <td class="px-4 py-4 whitespace-nowrap"><span :class="'inline-flex px-2 py-1 font-semibold text-xs rounded-full ' + statusClass(record.status)">{{ statusLabel(record.status) }}</span></td>
71
- <td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatHeartbeat(record.lastPing) }}</td>
72
- <td class="px-4 py-4 font-mono text-gray-700 text-right text-sm dark:text-neutral-200">{{ record.printCount.toLocaleString() }}</td>
73
- <td class="px-4 py-3 text-right whitespace-nowrap">
74
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'View ' + record.name" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button>
75
- <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>
76
- <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-waste"></span></Button>
77
- </td>
78
- </tr>
79
- </template>
80
- </tbody>
81
- </table>
82
- </div>
83
- </div>
71
+ {{--
72
+ No `searchable` or `filterable`: the parent dashboard owns the search box
73
+ and the filter selects. Table pages the whole filtered list the parent passes.
74
+ --}}
75
+ <Table
76
+ :sort-reset-key="sortResetKey()"
77
+ :page-reset-key="pageResetKey()"
78
+ :columns="COLUMNS"
79
+ :data="rows()"
80
+ :loading="loading()"
81
+ :row-actions="ROW_ACTIONS"
82
+ action-label-key="nameLabel"
83
+ paginated
84
+ :page-size="20"
85
+ empty-title="No print devices found"
86
+ empty-description="Add a printer or adjust the current filters."
87
+ @row-action="handleRowAction"
88
+ />
@@ -8,7 +8,6 @@ import type {
8
8
  import { formatCurrency } from '@stacksjs/commerce/money'
9
9
  import { dashboardApi } from '../../../../functions/dashboard-api'
10
10
  import { pushToast } from '../../../../functions/toasts'
11
- import { pageControls } from '../../../../functions/dashboard-list'
12
11
 
13
12
  const emptySummary: AbandonedCartSummary = {
14
13
  open: 0,
@@ -39,8 +38,6 @@ const search = state('')
39
38
  const stateFilter = state('open')
40
39
  const idleFilter = state('all')
41
40
  const sort = state('value')
42
- const page = state(1)
43
- const perPage = 10
44
41
  const dialogOpen = state(false)
45
42
  const saving = state(false)
46
43
  const saveError = state('')
@@ -73,23 +70,14 @@ function filteredRecords(): AbandonedCartRecord[] {
73
70
  })
74
71
  }
75
72
 
76
- function visibleRecords(): AbandonedCartRecord[] {
77
- const start = (page() - 1) * perPage
78
- return filteredRecords().slice(start, start + perPage)
79
- }
80
-
81
- function totalPages(): number {
82
- return Math.max(1, Math.ceil(filteredRecords().length / perPage))
73
+ /**
74
+ * What the table is showing: a change returns it to the first page, as this
75
+ * page's own pager did before the table took over paging.
76
+ */
77
+ function viewKey(): string {
78
+ return JSON.stringify([search(), stateFilter(), idleFilter(), sort()])
83
79
  }
84
80
 
85
- effect(() => {
86
- search()
87
- stateFilter()
88
- idleFilter()
89
- sort()
90
- page.set(1)
91
- })
92
-
93
81
  /** Cart totals and campaign thresholds are integer minor units (stacksjs/stacks#2851). */
94
82
  function money(value: number): string {
95
83
  return formatCurrency(value, summary().currency || defaultCurrency())
@@ -122,8 +110,6 @@ async function loadCarts(): Promise<void> {
122
110
  campaigns.set(data.campaigns)
123
111
  defaultCurrency.set(data.defaultCurrency || 'USD')
124
112
  defaultIdleHours.set(data.defaultIdleHours || 4)
125
- if (page() > totalPages())
126
- page.set(totalPages())
127
113
  }
128
114
  catch (error) {
129
115
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -163,8 +149,6 @@ async function createCampaign(payload: Record<string, unknown>): Promise<void> {
163
149
  }
164
150
  }
165
151
 
166
- const { previousPage, nextPage } = pageControls(page, totalPages)
167
-
168
152
  onMount(() => {
169
153
  void loadCarts()
170
154
  })
@@ -214,8 +198,7 @@ onMount(() => {
214
198
  <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>
215
199
  <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>
216
200
  </div>
217
- <AbandonedCartsTable :records="visibleRecords()" :loading="loading()" />
218
- <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>
201
+ <AbandonedCartsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" />
219
202
  </section>
220
203
  </div>
221
204