@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
  interface DeliveryRouteRow {
3
5
  id: number
@@ -12,97 +14,66 @@ interface DeliveryRouteRow {
12
14
  }
13
15
 
14
16
  const routes = useReactiveProp('routes', [] as DeliveryRouteRow[])
17
+ const loading = useReactiveProp('loading', false)
18
+ /** The parent's search: a change returns to the first page. */
19
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
15
20
  const emit = defineEmits()
16
21
 
17
- function formatDateTime(value: string): string {
18
- if (!value)
19
- return 'Not recorded'
22
+ function formatNumber(value: number): string {
23
+ return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value)
24
+ }
20
25
 
21
- const normalized = value.includes('T') ? value : value.replace(' ', 'T')
22
- const date = new Date(normalized)
23
- if (Number.isNaN(date.getTime()))
24
- return value
26
+ /** Time and distance carry their units, so they read as text and sort by the number. */
27
+ const COLUMNS = [
28
+ { key: 'routeLabel', label: 'Route', sortable: true, class: 'font-medium tabular-nums text-stone-800 whitespace-nowrap dark:text-white' },
29
+ { key: 'courierLabel', label: 'Courier', sortable: true, subtitleKey: 'courierNote', class: 'font-medium text-stone-800 whitespace-nowrap dark:text-white' },
30
+ { key: 'vehicle', label: 'Vehicle', sortable: true, class: 'whitespace-nowrap' },
31
+ { key: 'stops', label: 'Stops', type: 'number' as const, sortable: true, class: 'tabular-nums' },
32
+ { key: 'delivery_time', label: 'Delivery time', sortable: true, slotted: true },
33
+ { key: 'total_distance', label: 'Distance', sortable: true, slotted: true },
34
+ { key: 'last_active', label: 'Last active', type: 'datetime' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
35
+ ]
25
36
 
26
- return new Intl.DateTimeFormat('en-US', {
27
- dateStyle: 'medium',
28
- timeStyle: 'short',
29
- }).format(date)
30
- }
37
+ const ROW_ACTIONS = [
38
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
39
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
40
+ ]
31
41
 
32
- function formatNumber(value: number): string {
33
- return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value)
42
+ const rows = derived(() => routes().map(route => ({
43
+ ...route,
44
+ routeLabel: `#${route.id}`,
45
+ courierLabel: route.courier_record?.name || route.courier,
46
+ courierNote: route.courier_id ? `Linked courier #${route.courier_id}` : 'Legacy snapshot',
47
+ timeLabel: `${formatNumber(route.delivery_time)} min`,
48
+ distanceLabel: `${formatNumber(route.total_distance)} mi`,
49
+ actionName: `delivery route ${route.id} for ${route.courier}`,
50
+ })))
51
+
52
+ function handleRowAction(payload: { action: string, row: DeliveryRouteRow }): void {
53
+ emit(payload.action, payload.row)
34
54
  }
35
55
  </script>
36
56
 
37
- <div class="overflow-x-auto border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
38
- <table class="min-w-full divide-stone-100 divide-y dark:divide-neutral-700">
39
- <thead class="bg-stone-50/80 dark:bg-neutral-800">
40
- <tr>
41
- <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">Route</th>
42
- <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">Courier</th>
43
- <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">Vehicle</th>
44
- <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">Stops</th>
45
- <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">Time</th>
46
- <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">Distance</th>
47
- <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">Last Active</th>
48
- <th scope="col" class="relative pl-3 pr-6 py-3.5">
49
- <span class="sr-only">Actions</span>
50
- </th>
51
- </tr>
52
- </thead>
53
- <tbody class="bg-white dark:bg-neutral-800 divide-stone-100 divide-y dark:divide-neutral-700">
54
- <template :for="route in routes" :key="route.id">
55
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-700/50 transition-colors">
56
- <td class="pl-6 pr-3 py-3.5 font-medium tabular-nums text-sm text-stone-800 whitespace-nowrap dark:text-white">
57
- #{{ route.id }}
58
- </td>
59
- <td class="px-4 py-3.5 font-medium text-sm text-stone-800 whitespace-nowrap dark:text-white">
60
- <span class="block">{{ route.courier_record?.name || route.courier }}</span>
61
- <span class="block font-normal text-stone-400 text-xs dark:text-neutral-500">{{ route.courier_id ? 'Linked courier #' + route.courier_id : 'Legacy snapshot' }}</span>
62
- </td>
63
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
64
- {{ route.vehicle }}
65
- </td>
66
- <td class="px-4 py-3.5 tabular-nums text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
67
- {{ route.stops }}
68
- </td>
69
- <td class="px-4 py-3.5 tabular-nums text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
70
- {{ formatNumber(route.delivery_time) }} min
71
- </td>
72
- <td class="px-4 py-3.5 tabular-nums text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
73
- {{ formatNumber(route.total_distance) }} mi
74
- </td>
75
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
76
- {{ formatDateTime(route.last_active) }}
77
- </td>
78
- <td class="relative pl-3 pr-6 py-3.5 font-medium text-right text-sm whitespace-nowrap">
79
- <Button
80
- variant="ghost"
81
- size="sm"
82
- iconOnly
83
- @click="emit('edit', route)"
84
- :ariaLabel="'Edit delivery route ' + route.id + ' for ' + route.courier"
85
- >
86
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
87
- </Button>
88
- <Button
89
- variant="ghost"
90
- size="sm"
91
- iconOnly
92
- @click="emit('delete', route)"
93
- :ariaLabel="'Delete delivery route ' + route.id + ' for ' + route.courier"
94
- >
95
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
96
- </Button>
97
- </td>
98
- </tr>
99
- </template>
100
-
101
- <tr :if="routes().length === 0">
102
- <td colspan="8" class="px-6 py-12 text-center text-sm text-stone-500 dark:text-neutral-400">
103
- No delivery routes found.
104
- </td>
105
- </tr>
106
- </tbody>
107
- </table>
108
- </div>
57
+ {{--
58
+ No `searchable` or `filterable`: the parent dashboard owns the search box.
59
+ Table pages the whole filtered list the parent passes, and stays mounted
60
+ while it loads.
61
+ --}}
62
+ <Table
63
+ :page-reset-key="pageResetKey()"
64
+ :columns="COLUMNS"
65
+ :data="rows()"
66
+ :loading="loading()"
67
+ :row-actions="ROW_ACTIONS"
68
+ action-label-key="actionName"
69
+ paginated
70
+ :page-size="10"
71
+ empty-title="No delivery routes found"
72
+ empty-description="Add a route, or adjust the search."
73
+ @row-action="handleRowAction"
74
+ >
75
+ <template #cell="{ row, column }">
76
+ <span :if="column.key === 'delivery_time'" class="tabular-nums whitespace-nowrap" x-text="row.timeLabel"></span>
77
+ <span :if="column.key === 'total_distance'" class="tabular-nums whitespace-nowrap" x-text="row.distanceLabel"></span>
78
+ </template>
79
+ </Table>
@@ -29,8 +29,6 @@ const records = state<DigitalDeliveryRecord[]>([])
29
29
  const loading = state(true)
30
30
  const loadError = state('')
31
31
  const search = state('')
32
- const page = state(1)
33
- const perPage = state(10)
34
32
  const editorOpen = state(false)
35
33
  const editorMode = state<'create' | 'edit'>('create')
36
34
  const editing = state<DigitalDeliveryRecord | null>(null)
@@ -53,11 +51,6 @@ function filteredRecords(): DigitalDeliveryRecord[] {
53
51
  ].some(value => String(value).toLowerCase().includes(query)))
54
52
  }
55
53
 
56
- function paginatedRecords(): DigitalDeliveryRecord[] {
57
- const start = (page() - 1) * perPage()
58
- return filteredRecords().slice(start, start + perPage())
59
- }
60
-
61
54
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
62
55
  return [
63
56
  { label: 'Methods', value: records().length.toLocaleString(), detail: 'Persisted digital delivery records' },
@@ -75,9 +68,6 @@ async function loadMethods(): Promise<void> {
75
68
  if (!Array.isArray(data))
76
69
  throw new TypeError('Digital delivery records were not returned in the expected shape.')
77
70
  records.set(data)
78
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
79
- if (page() > lastPage)
80
- page.set(lastPage)
81
71
  }
82
72
  catch (error) {
83
73
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -90,7 +80,6 @@ async function loadMethods(): Promise<void> {
90
80
 
91
81
  function handleSearch(value: string): void {
92
82
  search.set(value)
93
- page.set(1)
94
83
  }
95
84
 
96
85
  function openCreate(): void {
@@ -139,7 +128,6 @@ async function saveMethod(payload: DigitalDeliveryPayload): Promise<void> {
139
128
  else {
140
129
  const nextRecords = [...records(), updated]
141
130
  records.set(nextRecords)
142
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
143
131
  }
144
132
  editorOpen.set(false)
145
133
  editing.set(null)
@@ -177,9 +165,6 @@ async function confirmDelete(): Promise<void> {
177
165
  })
178
166
  records.set(records().filter(record => record.id !== current.id))
179
167
  deleting.set(null)
180
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
181
- if (page() > lastPage)
182
- page.set(lastPage)
183
168
  pushToast('success', 'Digital delivery deleted', { detail: current.name })
184
169
  }
185
170
  catch (error) {
@@ -190,10 +175,6 @@ async function confirmDelete(): Promise<void> {
190
175
  }
191
176
  }
192
177
 
193
- function goToPage(value: number): void {
194
- page.set(value)
195
- }
196
-
197
178
  onMount(() => {
198
179
  void loadMethods()
199
180
  })
@@ -218,11 +199,7 @@ onMount(() => {
218
199
  <div><h2 class="font-semibold text-gray-900 dark:text-white">Delivery methods</h2><p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ filteredRecords().length }} matching records</p></div>
219
200
  <SearchFilter placeholder="Search digital delivery" class="w-full sm:w-80" @search="handleSearch" />
220
201
  </div>
221
- <div :if="loading()" class="flex items-center justify-center py-24 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading digital delivery</div>
222
- <DigitalDeliveryTable :if="!loading()" :methods="paginatedRecords()" @edit="openEdit" @delete="requestDelete" />
223
- <div :if="!loading()" class="px-4 py-3 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
224
- <DeliveryPagination :current-page="page()" :total-items="filteredRecords().length" :items-per-page="perPage()" @select="goToPage" />
225
- </div>
202
+ <DigitalDeliveryTable :methods="filteredRecords()" :loading="loading()" :page-reset-key="search()" @edit="openEdit" @delete="requestDelete" />
226
203
  </section>
227
204
  </div>
228
205
 
@@ -1,3 +1,5 @@
1
+ @import('../../UI/Table')
2
+
1
3
  <script client>
2
4
  type DigitalDeliveryStatus = 'active' | 'inactive'
3
5
 
@@ -13,89 +15,69 @@ interface DigitalDeliveryRow {
13
15
  }
14
16
 
15
17
  const methods = useReactiveProp('methods', [] as DigitalDeliveryRow[])
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
- function statusClass(status: DigitalDeliveryStatus): string {
19
- return status === 'active'
20
- ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300'
21
- : 'bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-neutral-700 dark:text-neutral-300'
22
- }
23
+ /** Expiry keeps its unit and sorts by the number of days. */
24
+ const COLUMNS = [
25
+ { key: 'name', label: 'Method', sortable: true, class: 'font-medium text-stone-800 whitespace-nowrap dark:text-white' },
26
+ { key: 'description', label: 'Description', class: 'max-w-xs truncate' },
27
+ { key: 'download_limit', label: 'Downloads', type: 'number' as const, sortable: true, emptyText: 'Unlimited' },
28
+ { key: 'expiry_days', label: 'Expiry', sortable: true, slotted: true },
29
+ { key: 'requiresLogin', label: 'Login', type: 'boolean' as const, sortable: true },
30
+ { key: 'automatic', label: 'Automatic', type: 'boolean' as const, sortable: true },
31
+ {
32
+ key: 'status',
33
+ label: 'Status',
34
+ type: 'badge' as const,
35
+ sortable: true,
36
+ badgeClasses: {
37
+ active: 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300',
38
+ inactive: 'bg-stone-100 text-stone-600 dark:bg-neutral-700 dark:text-neutral-300',
39
+ },
40
+ badgeLabels: { active: 'Active', inactive: 'Inactive' },
41
+ },
42
+ ]
43
+
44
+ const ROW_ACTIONS = [
45
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
46
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
47
+ ]
23
48
 
24
- function yesNo(value: boolean | number): string {
25
- return Boolean(value) ? 'Yes' : 'No'
49
+ const rows = derived(() => methods().map(method => ({
50
+ ...method,
51
+ expiryLabel: `${method.expiry_days} days`,
52
+ requiresLogin: Boolean(method.requires_login),
53
+ automatic: Boolean(method.automatic_delivery),
54
+ actionName: `digital delivery method ${method.name}, record ${method.id}`,
55
+ })))
56
+
57
+ function handleRowAction(payload: { action: string, row: DigitalDeliveryRow }): void {
58
+ emit(payload.action, payload.row)
26
59
  }
27
60
  </script>
28
61
 
29
- <div class="overflow-x-auto border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
30
- <table class="min-w-full divide-stone-100 divide-y dark:divide-neutral-700">
31
- <thead class="bg-stone-50/80 dark:bg-neutral-800">
32
- <tr>
33
- <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">Method</th>
34
- <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">Description</th>
35
- <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">Downloads</th>
36
- <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">Expiry</th>
37
- <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">Login</th>
38
- <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">Automatic</th>
39
- <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>
40
- <th scope="col" class="relative pl-3 pr-6 py-3.5">
41
- <span class="sr-only">Actions</span>
42
- </th>
43
- </tr>
44
- </thead>
45
- <tbody class="bg-white dark:bg-neutral-800 divide-stone-100 divide-y dark:divide-neutral-700">
46
- <template :for="method in methods" :key="method.id">
47
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-700/50 transition-colors">
48
- <td class="pl-6 pr-3 py-3.5 font-medium text-sm text-stone-800 whitespace-nowrap dark:text-white">
49
- {{ method.name }}
50
- </td>
51
- <td class="px-4 py-3.5 max-w-72 text-sm text-stone-500 dark:text-neutral-300">
52
- <span class="block truncate" :title="method.description">{{ method.description }}</span>
53
- </td>
54
- <td class="px-4 py-3.5 tabular-nums text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
55
- {{ method.download_limit ?? 'Unlimited' }}
56
- </td>
57
- <td class="px-4 py-3.5 tabular-nums text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
58
- {{ method.expiry_days }} days
59
- </td>
60
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
61
- {{ yesNo(method.requires_login) }}
62
- </td>
63
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
64
- {{ yesNo(method.automatic_delivery) }}
65
- </td>
66
- <td class="px-4 py-3.5 text-sm whitespace-nowrap">
67
- <span :class="'inline-flex items-center px-2 py-1 font-medium text-xs ring-1 ring-inset rounded-md ' + statusClass(method.status)">
68
- {{ method.status === 'active' ? 'Active' : 'Inactive' }}
69
- </span>
70
- </td>
71
- <td class="relative pl-3 pr-6 py-3.5 font-medium text-right text-sm whitespace-nowrap">
72
- <Button
73
- variant="ghost"
74
- size="sm"
75
- iconOnly
76
- @click="emit('edit', method)"
77
- :ariaLabel="'Edit digital delivery method ' + method.name + ', record ' + method.id"
78
- >
79
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
80
- </Button>
81
- <Button
82
- variant="ghost"
83
- size="sm"
84
- iconOnly
85
- @click="emit('delete', method)"
86
- :ariaLabel="'Delete digital delivery method ' + method.name + ', record ' + method.id"
87
- >
88
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
89
- </Button>
90
- </td>
91
- </tr>
92
- </template>
93
-
94
- <tr :if="methods().length === 0">
95
- <td colspan="8" class="px-6 py-12 text-center text-sm text-stone-500 dark:text-neutral-400">
96
- No digital delivery methods found.
97
- </td>
98
- </tr>
99
- </tbody>
100
- </table>
101
- </div>
62
+ {{--
63
+ No `searchable` or `filterable`: the parent dashboard owns the search box.
64
+ Table pages the whole filtered list the parent passes, and stays mounted
65
+ while it loads.
66
+ --}}
67
+ <Table
68
+ :page-reset-key="pageResetKey()"
69
+ :columns="COLUMNS"
70
+ :data="rows()"
71
+ :loading="loading()"
72
+ :row-actions="ROW_ACTIONS"
73
+ action-label-key="actionName"
74
+ paginated
75
+ :page-size="10"
76
+ empty-title="No digital delivery methods found"
77
+ empty-description="Add a method, or adjust the search."
78
+ @row-action="handleRowAction"
79
+ >
80
+ <template #cell="{ row }">
81
+ <span class="whitespace-nowrap" x-text="row.expiryLabel"></span>
82
+ </template>
83
+ </Table>
@@ -60,8 +60,6 @@ const {
60
60
  loading,
61
61
  loadError,
62
62
  search,
63
- page,
64
- perPage,
65
63
  editorOpen,
66
64
  editorMode,
67
65
  editing,
@@ -71,8 +69,6 @@ const {
71
69
  deleteBusy,
72
70
  deleteError,
73
71
  filteredRecords,
74
- paginatedRecords,
75
- goToPage,
76
72
  handleSearch,
77
73
  openCreate,
78
74
  openEdit,
@@ -81,7 +77,6 @@ const {
81
77
  closeDelete,
82
78
  upsert,
83
79
  remove,
84
- clampPage,
85
80
  } = useRecordsDashboard<LicenseKeyRecord>({ filter: matchesQuery })
86
81
 
87
82
  const options = state<LicenseKeyOptions>(emptyOptions)
@@ -132,7 +127,6 @@ async function loadKeys(): Promise<void> {
132
127
  throw new TypeError('License key resources were not returned in the expected shape.')
133
128
  options.set(optionData)
134
129
  records.set(keyData.map(withRelationships))
135
- clampPage()
136
130
  }
137
131
  catch (error) {
138
132
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -187,7 +181,6 @@ async function confirmDelete(): Promise<void> {
187
181
  })
188
182
  remove(current)
189
183
  deleting.set(null)
190
- clampPage()
191
184
  pushToast('success', 'License key deleted', { detail: current.key })
192
185
  }
193
186
  catch (error) {
@@ -222,11 +215,7 @@ onMount(() => {
222
215
  <div><h2 class="font-semibold text-gray-900 dark:text-white">Key inventory</h2><p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ filteredRecords().length }} matching records</p></div>
223
216
  <SearchFilter placeholder="Search license keys" class="w-full sm:w-80" @search="handleSearch" />
224
217
  </div>
225
- <div :if="loading()" class="flex items-center justify-center py-24 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading license keys</div>
226
- <LicenseKeysTable :if="!loading()" :license-keys="paginatedRecords()" @edit="openEdit" @delete="requestDelete" />
227
- <div :if="!loading()" class="px-4 py-3 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
228
- <DeliveryPagination :current-page="page()" :total-items="filteredRecords().length" :items-per-page="perPage()" @select="goToPage" />
229
- </div>
218
+ <LicenseKeysTable :license-keys="filteredRecords()" :loading="loading()" :page-reset-key="search()" @edit="openEdit" @delete="requestDelete" />
230
219
  </section>
231
220
  </div>
232
221
 
@@ -1,3 +1,5 @@
1
+ @import('../../UI/Table')
2
+
1
3
  <script client>
2
4
  type LicenseKeyStatus = 'active' | 'inactive' | 'unassigned'
3
5
 
@@ -23,110 +25,67 @@ interface LicenseKeyRow {
23
25
  }
24
26
 
25
27
  const licenseKeys = useReactiveProp('license-keys', [] as LicenseKeyRow[])
28
+ const loading = useReactiveProp('loading', false)
29
+ /** The parent's search: a change returns to the first page. */
30
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
26
31
  const emit = defineEmits()
27
32
 
28
- function formatDate(value: string): string {
29
- if (!value)
30
- return 'Not set'
33
+ const COLUMNS = [
34
+ { key: 'key', label: 'Key', sortable: true, class: 'font-medium font-mono text-stone-800 whitespace-nowrap dark:text-white' },
35
+ { key: 'template', label: 'Template', sortable: true, class: 'whitespace-nowrap' },
36
+ { key: 'customerLabel', label: 'Customer', sortable: true, subtitleKey: 'customerDetail', class: 'whitespace-nowrap' },
37
+ { key: 'productLabel', label: 'Product', sortable: true, class: 'whitespace-nowrap' },
38
+ { key: 'orderLabel', label: 'Order', sortable: true, class: 'whitespace-nowrap' },
39
+ { key: 'created_at', label: 'Created', type: 'date' as const, sortable: true, emptyText: 'Not set', class: 'whitespace-nowrap' },
40
+ { key: 'expiry_date', label: 'Expires', type: 'date' as const, sortable: true, emptyText: 'Not set', class: 'whitespace-nowrap' },
41
+ {
42
+ key: 'status',
43
+ label: 'Status',
44
+ type: 'badge' as const,
45
+ sortable: true,
46
+ badgeClasses: {
47
+ active: 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300',
48
+ inactive: 'bg-stone-100 text-stone-600 dark:bg-neutral-700 dark:text-neutral-300',
49
+ unassigned: 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
50
+ },
51
+ badgeLabels: { active: 'Active', inactive: 'Inactive', unassigned: 'Unassigned' },
52
+ },
53
+ ]
31
54
 
32
- const normalized = value.includes('T') ? value : value.replace(' ', 'T')
33
- const date = new Date(normalized)
34
- if (Number.isNaN(date.getTime()))
35
- return value
55
+ const ROW_ACTIONS = [
56
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
57
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
58
+ ]
36
59
 
37
- return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(date)
38
- }
60
+ const rows = derived(() => licenseKeys().map(licenseKey => ({
61
+ ...licenseKey,
62
+ customerLabel: licenseKey.customer?.label || 'Unassigned',
63
+ customerDetail: licenseKey.customer?.detail || '',
64
+ productLabel: licenseKey.product?.label || 'Unassigned',
65
+ orderLabel: licenseKey.order?.label || 'Unassigned',
66
+ actionName: `license key ${licenseKey.key}, record ${licenseKey.id}`,
67
+ })))
39
68
 
40
- function statusLabel(status: LicenseKeyStatus): string {
41
- return status === 'unassigned' ? 'Unassigned' : status === 'active' ? 'Active' : 'Inactive'
42
- }
43
-
44
- function statusClass(status: LicenseKeyStatus): string {
45
- if (status === 'active')
46
- return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300'
47
- if (status === 'inactive')
48
- return 'bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-neutral-700 dark:text-neutral-300'
49
- return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300'
69
+ function handleRowAction(payload: { action: string, row: LicenseKeyRow }): void {
70
+ emit(payload.action, payload.row)
50
71
  }
51
72
  </script>
52
73
 
53
- <div class="overflow-x-auto border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
54
- <table class="min-w-full divide-stone-100 divide-y dark:divide-neutral-700">
55
- <thead class="bg-stone-50/80 dark:bg-neutral-800">
56
- <tr>
57
- <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">Key</th>
58
- <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">Template</th>
59
- <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">Customer</th>
60
- <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">Product</th>
61
- <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">Order</th>
62
- <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">Created</th>
63
- <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">Expires</th>
64
- <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>
65
- <th scope="col" class="relative pl-3 pr-6 py-3.5">
66
- <span class="sr-only">Actions</span>
67
- </th>
68
- </tr>
69
- </thead>
70
- <tbody class="bg-white dark:bg-neutral-800 divide-stone-100 divide-y dark:divide-neutral-700">
71
- <template :for="licenseKey in licenseKeys" :key="licenseKey.id">
72
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-700/50 transition-colors">
73
- <td class="pl-6 pr-3 py-3.5 font-medium font-mono text-sm text-stone-800 whitespace-nowrap dark:text-white">
74
- {{ licenseKey.key }}
75
- </td>
76
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
77
- {{ licenseKey.template }}
78
- </td>
79
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
80
- <span class="block">{{ licenseKey.customer?.label || 'Unassigned' }}</span>
81
- <span :if="licenseKey.customer?.detail" class="block text-stone-400 text-xs dark:text-neutral-500">
82
- {{ licenseKey.customer?.detail }}
83
- </span>
84
- </td>
85
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
86
- {{ licenseKey.product?.label || 'Unassigned' }}
87
- </td>
88
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
89
- {{ licenseKey.order?.label || 'Unassigned' }}
90
- </td>
91
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
92
- {{ formatDate(licenseKey.created_at) }}
93
- </td>
94
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
95
- {{ formatDate(licenseKey.expiry_date) }}
96
- </td>
97
- <td class="px-4 py-3.5 text-sm whitespace-nowrap">
98
- <span :class="'inline-flex items-center px-2 py-1 font-medium text-xs ring-1 ring-inset rounded-md ' + statusClass(licenseKey.status)">
99
- {{ statusLabel(licenseKey.status) }}
100
- </span>
101
- </td>
102
- <td class="relative pl-3 pr-6 py-3.5 font-medium text-right text-sm whitespace-nowrap">
103
- <Button
104
- variant="ghost"
105
- size="sm"
106
- iconOnly
107
- @click="emit('edit', licenseKey)"
108
- :ariaLabel="'Edit license key ' + licenseKey.key + ', record ' + licenseKey.id"
109
- >
110
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
111
- </Button>
112
- <Button
113
- variant="ghost"
114
- size="sm"
115
- iconOnly
116
- @click="emit('delete', licenseKey)"
117
- :ariaLabel="'Delete license key ' + licenseKey.key + ', record ' + licenseKey.id"
118
- >
119
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
120
- </Button>
121
- </td>
122
- </tr>
123
- </template>
124
-
125
- <tr :if="licenseKeys().length === 0">
126
- <td colspan="9" class="px-6 py-12 text-center text-sm text-stone-500 dark:text-neutral-400">
127
- No license keys found.
128
- </td>
129
- </tr>
130
- </tbody>
131
- </table>
132
- </div>
74
+ {{--
75
+ No `searchable` or `filterable`: the parent dashboard owns the search box.
76
+ Table pages the whole filtered list the parent passes, and stays mounted
77
+ while it loads.
78
+ --}}
79
+ <Table
80
+ :page-reset-key="pageResetKey()"
81
+ :columns="COLUMNS"
82
+ :data="rows()"
83
+ :loading="loading()"
84
+ :row-actions="ROW_ACTIONS"
85
+ action-label-key="actionName"
86
+ paginated
87
+ :page-size="10"
88
+ empty-title="No license keys found"
89
+ empty-description="Add a license key, or adjust the search."
90
+ @row-action="handleRowAction"
91
+ />