@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,66 +1,93 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { CommerceOrderRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
3
- import { formatCurrency } from '@stacksjs/commerce/money'
4
5
 
5
6
  const records = useReactiveProp('records', [] as CommerceOrderRecord[])
6
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', '')
7
12
  const emit = defineEmits()
8
13
 
9
14
  function label(value: string): string {
10
15
  return value.replaceAll('_', ' ').toLowerCase().replace(/\b\w/g, letter => letter.toUpperCase())
11
16
  }
12
17
 
13
- function formatMoney(value: number, currency: string): string {
14
- return formatCurrency(value, currency)
18
+ /**
19
+ * The pill's colour by tone rather than by status, so a status the table
20
+ * has not named still reads as pending, as the hand-rolled pill did.
21
+ */
22
+ function statusTone(status: string): string {
23
+ const value = status.toUpperCase()
24
+ if (['DELIVERED', 'COMPLETED', 'PAID'].includes(value))
25
+ return 'done'
26
+ if (['CANCELED', 'CANCELLED', 'REFUNDED'].includes(value))
27
+ return 'stopped'
28
+ if (['PREPARING', 'PROCESSING', 'READY', 'SHIPPED'].includes(value))
29
+ return 'moving'
30
+ return 'pending'
15
31
  }
16
32
 
17
- function formatDate(value: string): string {
18
- if (!value)
19
- return 'Not recorded'
20
- const normalized = /^\d{4}-\d{2}-\d{2} \d/.test(value)
21
- ? `${value.replace(' ', 'T')}Z`
22
- : value
23
- const date = new Date(normalized)
24
- return Number.isNaN(date.getTime())
25
- ? value
26
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
27
- }
33
+ const COLUMNS = [
34
+ { key: 'orderLabel', label: 'Order', sortable: true, subtitleKey: 'itemsLabel', class: 'font-medium text-gray-900 whitespace-nowrap dark:text-white' },
35
+ { key: 'customerLabel', label: 'Customer', sortable: true, subtitleKey: 'customerEmail', class: 'text-gray-700 dark:text-neutral-200' },
36
+ // Integer minor units of the order's own currency.
37
+ { key: 'totalAmount', label: 'Total', type: 'currency' as const, currencyKey: 'currency', sortable: true, class: 'font-medium' },
38
+ { key: 'fulfillmentLabel', label: 'Fulfillment', sortable: true, subtitleKey: 'deliveryAddress', class: 'text-gray-700 dark:text-neutral-200' },
39
+ {
40
+ key: 'statusTone',
41
+ label: 'Status',
42
+ type: 'badge' as const,
43
+ sortable: true,
44
+ badgeLabelKey: 'statusLabel',
45
+ badgeClasses: {
46
+ done: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
47
+ stopped: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
48
+ moving: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
49
+ pending: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
50
+ },
51
+ },
52
+ { key: 'createdAt', label: 'Created', type: 'datetime' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
53
+ ]
54
+
55
+ const ROW_ACTIONS = [
56
+ { action: 'view', label: 'View order', icon: 'i-hugeicons-view' },
57
+ { action: 'edit', label: 'Edit order', icon: 'i-hugeicons-edit-02' },
58
+ { action: 'delete', label: 'Delete order', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
59
+ ]
60
+
61
+ const rows = derived(() => records().map(record => ({
62
+ ...record,
63
+ orderLabel: `ORD-${record.id.padStart(5, '0')}`,
64
+ itemsLabel: `${record.itemCount} ${record.itemCount === 1 ? 'item' : 'items'}`,
65
+ customerLabel: record.customerName || 'Unassigned customer',
66
+ fulfillmentLabel: label(record.orderType),
67
+ statusTone: statusTone(record.status),
68
+ statusLabel: label(record.status),
69
+ })))
28
70
 
29
- function statusClass(record: CommerceOrderRecord): string {
30
- const status = record.status.toUpperCase()
31
- if (['DELIVERED', 'COMPLETED', 'PAID'].includes(status))
32
- return 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300'
33
- if (['CANCELED', 'CANCELLED', 'REFUNDED'].includes(status))
34
- return 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'
35
- if (['PREPARING', 'PROCESSING', 'READY', 'SHIPPED'].includes(status))
36
- return 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'
37
- return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
71
+ function handleRowAction(payload: { action: string, row: CommerceOrderRecord }): void {
72
+ emit(payload.action, payload.row)
38
73
  }
39
74
  </script>
40
75
 
41
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
42
- <div :if="loading()" class="flex items-center justify-center py-16 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 orders</div>
43
- <div :if="!loading() && records().length === 0" class="py-16 text-center">
44
- <div class="mb-3 mx-auto h-8 w-8 text-gray-400 i-hugeicons-invoice-03"></div>
45
- <p class="font-medium text-gray-900 text-sm dark:text-white">No orders found</p>
46
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Adjust the filters or add a native Order record.</p>
47
- </div>
48
- <div :if="!loading() && records().length > 0" 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/60"><tr><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Order</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Customer</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Total</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Fulfillment</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Status</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Created</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead>
51
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
52
- <template :for="record in records()" :key="record.id">
53
- <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
54
- <td class="px-4 py-3 min-w-28 whitespace-nowrap"><Button variant="ghost" size="xs" @click="emit('view', record)">ORD-{{ record.id.padStart(5, '0') }}</Button><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.itemCount }} {{ record.itemCount === 1 ? 'item' : 'items' }}</p></td>
55
- <td class="px-4 py-3 min-w-40"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.customerName || 'Unassigned customer' }}</p><p :if="record.customerEmail" class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.customerEmail }}</p></td>
56
- <td class="px-4 py-3 font-medium text-gray-700 text-sm dark:text-neutral-200">{{ formatMoney(record.totalAmount, record.currency) }}</td>
57
- <td class="px-4 py-3 min-w-36"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ label(record.orderType) }}</p><p :if="record.deliveryAddress" class="mt-1 max-w-48 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.deliveryAddress }}</p></td>
58
- <td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record)">{{ label(record.status) }}</span></td>
59
- <td class="px-4 py-3 text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200">{{ formatDate(record.createdAt) }}</td>
60
- <td class="px-4 py-3"><div class="flex gap-1 justify-end"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'View order ' + record.id" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit order ' + record.id" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-02"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete order ' + record.id" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td>
61
- </tr>
62
- </template>
63
- </tbody>
64
- </table>
65
- </div>
66
- </div>
76
+ {{--
77
+ No `searchable` or `filterable`: the parent dashboard owns the search box
78
+ and the filter selects. Table pages the whole filtered list the parent passes.
79
+ --}}
80
+ <Table
81
+ :sort-reset-key="sortResetKey()"
82
+ :page-reset-key="pageResetKey()"
83
+ :columns="COLUMNS"
84
+ :data="rows()"
85
+ :loading="loading()"
86
+ :row-actions="ROW_ACTIONS"
87
+ action-label-key="id"
88
+ paginated
89
+ :page-size="12"
90
+ empty-title="No orders found"
91
+ empty-description="Adjust the filters or add a native Order record."
92
+ @row-action="handleRowAction"
93
+ />
@@ -200,7 +200,7 @@ onMount(() => {
200
200
  <label class="flex gap-2 items-center text-gray-500 text-sm dark:text-neutral-400">Sort<select x-model="sort" class="px-3 py-1.5 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="oldest">Oldest</option><option value="amount">Highest amount</option><option value="status">Status</option></select></label>
201
201
  </div>
202
202
 
203
- <CommercePaymentsTable :records="visibleRecords()" :loading="loading()" @view="openDetails" />
203
+ <CommercePaymentsTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @view="openDetails" />
204
204
  </section>
205
205
  </div>
206
206
 
@@ -6,6 +6,8 @@ import { formatCurrency } from '@stacksjs/commerce/money'
6
6
 
7
7
  const records = useReactiveProp('records', [] as PaymentRecord[])
8
8
  const loading = useReactiveProp('loading', false)
9
+ /** The parent's sort select; a change hands the order back from a header sort. */
10
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
9
11
  const emit = defineEmits()
10
12
 
11
13
  /** `partiallyRefunded` and `bank_transfer` as words, each capitalized. */
@@ -83,6 +85,7 @@ function handleRowAction(payload: { action: string, row: PaymentRecord }): void
83
85
  </script>
84
86
 
85
87
  <Table
88
+ :sort-reset-key="sortResetKey()"
86
89
  :columns="COLUMNS"
87
90
  :data="rows()"
88
91
  :loading="loading()"
@@ -8,7 +8,6 @@ import type {
8
8
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
9
9
  import { pushToast } from '../../../../functions/toasts'
10
10
  import { minorToInput } from '@stacksjs/commerce/money'
11
- import { pageControls } from '../../../../functions/dashboard-list'
12
11
 
13
12
  interface ProductIndexResponse {
14
13
  records: CommerceProductRecord[]
@@ -34,8 +33,6 @@ const search = state('')
34
33
  const categoryFilter = state('all')
35
34
  const availabilityFilter = state('all')
36
35
  const sort = state('newest')
37
- const page = state(1)
38
- const pageSize = 10
39
36
  const loading = state(true)
40
37
  const loadError = state('')
41
38
  const dialogOpen = state(false)
@@ -94,15 +91,6 @@ function filteredRecords(): CommerceProductRecord[] {
94
91
  })
95
92
  }
96
93
 
97
- function pageCount(): number {
98
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
99
- }
100
-
101
- function visibleRecords(): CommerceProductRecord[] {
102
- const selectedPage = Math.min(page(), pageCount())
103
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
104
- }
105
-
106
94
  function matchingLabel(): string {
107
95
  const count = filteredRecords().length
108
96
  return `${count} matching ${count === 1 ? 'product' : 'products'}`
@@ -146,13 +134,13 @@ function exportHref(): string {
146
134
  return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
147
135
  }
148
136
 
149
- effect(() => {
150
- search()
151
- categoryFilter()
152
- availabilityFilter()
153
- sort()
154
- page.set(1)
155
- })
137
+ /**
138
+ * What the table is showing: a change returns it to the first page, as this
139
+ * page's own pager did before the table took over paging.
140
+ */
141
+ function viewKey(): string {
142
+ return JSON.stringify([search(), categoryFilter(), availabilityFilter(), sort()])
143
+ }
156
144
 
157
145
  async function loadProducts(): Promise<void> {
158
146
  loading.set(true)
@@ -164,7 +152,6 @@ async function loadProducts(): Promise<void> {
164
152
  categories.set(Array.isArray(data.categories) ? data.categories : [])
165
153
  manufacturers.set(Array.isArray(data.manufacturers) ? data.manufacturers : [])
166
154
  defaultCurrency.set(data.defaultCurrency || 'USD')
167
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
168
155
  }
169
156
  catch (error) {
170
157
  loadError.set('Product records could not be loaded.')
@@ -260,8 +247,6 @@ async function deleteProduct(): Promise<void> {
260
247
  }
261
248
  }
262
249
 
263
- const { previousPage, nextPage } = pageControls(page, pageCount)
264
-
265
250
  onMount(() => {
266
251
  void loadProducts()
267
252
  })
@@ -294,14 +279,9 @@ onMount(() => {
294
279
 
295
280
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
296
281
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ matchingLabel() }}</p>
297
- <div class="flex gap-2 items-center">
298
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
299
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
300
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
301
- </div>
302
282
  </div>
303
283
 
304
- <CommerceProductsTable :records="visibleRecords()" :loading="loading()" :currency="defaultCurrency()" @view="viewProduct" @edit="openEdit" @delete="openDelete" />
284
+ <CommerceProductsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" :currency="defaultCurrency()" @view="viewProduct" @edit="openEdit" @delete="openDelete" />
305
285
  </section>
306
286
  </div>
307
287
 
@@ -1,9 +1,14 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { CommerceProductRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-product-records'
3
- import { formatCurrency } from '@stacksjs/commerce/money'
4
5
 
5
6
  const records = useReactiveProp('records', [] as CommerceProductRecord[])
6
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', '')
7
12
  const currency = useReactiveProp('currency', 'USD')
8
13
  const emit = defineEmits()
9
14
 
@@ -11,40 +16,82 @@ function initials(name: string): string {
11
16
  return name.split(/\s+/).filter(Boolean).slice(0, 2).map(part => part[0]?.toUpperCase() || '').join('') || 'PR'
12
17
  }
13
18
 
14
- function formatMoney(value: number): string {
15
- return formatCurrency(value, currency())
16
- }
19
+ /**
20
+ * The product cell is slotted for its initials, and inventory for its colour:
21
+ * none in stock reads red. Everything else is a column option.
22
+ */
23
+ const COLUMNS = [
24
+ { key: 'nameLabel', label: 'Product', sortable: true, slotted: true },
25
+ { key: 'categoryName', label: 'Relationships', sortable: true, subtitleKey: 'manufacturerName', class: 'text-gray-700 dark:text-neutral-200' },
26
+ { key: 'price', label: 'Price', type: 'currency' as const, currencyKey: 'currency', sortable: true, class: 'font-medium whitespace-nowrap' },
27
+ { key: 'inventoryCount', label: 'Inventory', sortable: true, slotted: true },
28
+ {
29
+ key: 'availability',
30
+ label: 'Availability',
31
+ type: 'badge' as const,
32
+ sortable: true,
33
+ badgeClasses: { available: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300' },
34
+ badgeLabels: { available: 'Available', unavailable: 'Unavailable' },
35
+ },
36
+ { key: 'variantsLabel', label: 'Related', subtitleKey: 'reviewsLabel', searchable: false, class: 'text-gray-500 text-xs dark:text-neutral-400' },
37
+ ]
38
+
39
+ const ROW_ACTIONS = [
40
+ { action: 'view', label: 'View product', icon: 'i-hugeicons-view' },
41
+ { action: 'edit', label: 'Edit product', icon: 'i-hugeicons-edit-02' },
42
+ { action: 'delete', label: 'Delete product', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
43
+ ]
44
+
45
+ const rows = derived(() => records().map(record => ({
46
+ ...record,
47
+ currency: currency(),
48
+ nameLabel: record.name || 'Unnamed product',
49
+ initials: initials(record.name),
50
+ descriptionLabel: record.description || 'No description',
51
+ outOfStock: record.inventoryCount === 0,
52
+ inventoryLabel: record.inventoryCount.toLocaleString(),
53
+ prepLabel: `${record.preparationTime} min prep`,
54
+ availability: record.isAvailable ? 'available' : 'unavailable',
55
+ variantsLabel: `${record.variantCount} variants, ${record.unitCount} units`,
56
+ reviewsLabel: `${record.reviewCount} reviews`,
57
+ })))
17
58
 
18
- function availabilityClass(record: CommerceProductRecord): string {
19
- return record.isAvailable
20
- ? 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300'
21
- : 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
59
+ function handleRowAction(payload: { action: string, row: CommerceProductRecord }): void {
60
+ emit(payload.action, payload.row)
22
61
  }
23
62
  </script>
24
63
 
25
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
26
- <div :if="loading()" class="flex items-center justify-center py-16 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 products</div>
27
- <div :if="!loading() && records().length === 0" class="py-16 text-center">
28
- <div class="mb-3 mx-auto h-8 w-8 text-gray-400 i-hugeicons-package"></div>
29
- <p class="font-medium text-gray-900 text-sm dark:text-white">No products found</p>
30
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Adjust the filters or add a native Product record.</p>
31
- </div>
32
- <div :if="!loading() && records().length > 0" class="overflow-x-auto">
33
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
34
- <thead class="bg-gray-50 dark:bg-neutral-900/60"><tr><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Product</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Relationships</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Price</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Inventory</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Availability</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Related</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead>
35
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
36
- <template :for="record in records()" :key="record.id">
37
- <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
38
- <td class="px-4 py-3 min-w-56"><div class="flex gap-3 items-center"><div class="flex flex-none items-center justify-center h-9 w-9 font-semibold text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md">{{ initials(record.name) }}</div><div class="min-w-0"><Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.name || 'Unnamed product' }}</Button><p class="mt-1 max-w-52 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.description || 'No description' }}</p></div></div></td>
39
- <td class="px-4 py-3 min-w-44"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.categoryName }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.manufacturerName }}</p></td>
40
- <td class="px-4 py-3 font-medium text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200">{{ formatMoney(record.price) }}</td>
41
- <td class="px-4 py-3 min-w-28"><p :class="'font-medium text-sm ' + (record.inventoryCount === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-neutral-200')">{{ record.inventoryCount.toLocaleString() }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.preparationTime }} min prep</p></td>
42
- <td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + availabilityClass(record)">{{ record.isAvailable ? 'Available' : 'Unavailable' }}</span></td>
43
- <td class="px-4 py-3 min-w-36 text-gray-500 text-xs dark:text-neutral-400"><p>{{ record.variantCount }} variants, {{ record.unitCount }} units</p><p class="mt-1">{{ record.reviewCount }} reviews</p></td>
44
- <td class="px-4 py-3"><div class="flex gap-1 justify-end"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'View product ' + record.name" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit product ' + record.name" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-02"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete product ' + record.name" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td>
45
- </tr>
46
- </template>
47
- </tbody>
48
- </table>
49
- </div>
50
- </div>
64
+ {{--
65
+ No `searchable` or `filterable`: the parent dashboard owns the search box
66
+ and the filter selects. Table pages the whole filtered list the parent passes.
67
+ --}}
68
+ <Table
69
+ :sort-reset-key="sortResetKey()"
70
+ :page-reset-key="pageResetKey()"
71
+ :columns="COLUMNS"
72
+ :data="rows()"
73
+ :loading="loading()"
74
+ :row-actions="ROW_ACTIONS"
75
+ action-label-key="nameLabel"
76
+ paginated
77
+ :page-size="10"
78
+ empty-title="No products found"
79
+ empty-description="Adjust the filters or add a native Product record."
80
+ @row-action="handleRowAction"
81
+ >
82
+ <template #cell="{ row, column }">
83
+ <template :if="column.key === 'nameLabel'">
84
+ <div class="flex gap-3 items-center">
85
+ <div class="flex flex-none items-center justify-center h-9 w-9 font-semibold text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md" x-text="row.initials"></div>
86
+ <div class="min-w-0">
87
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.nameLabel"></p>
88
+ <p class="mt-1 max-w-52 text-gray-400 text-xs truncate dark:text-neutral-500" x-text="row.descriptionLabel"></p>
89
+ </div>
90
+ </div>
91
+ </template>
92
+ <template :if="column.key === 'inventoryCount'">
93
+ <p :class="'font-medium text-sm ' + (row.outOfStock ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-neutral-200')" x-text="row.inventoryLabel"></p>
94
+ <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500" x-text="row.prepLabel"></p>
95
+ </template>
96
+ </template>
97
+ </Table>
@@ -43,8 +43,6 @@ const productFilter = state('all')
43
43
  const ratingFilter = state('all')
44
44
  const moderationFilter = state('all')
45
45
  const sort = state('newest')
46
- const page = state(1)
47
- const pageSize = 12
48
46
  const loading = state(true)
49
47
  const loadError = state('')
50
48
  const editingReview = state<ReviewRecord | null>(null)
@@ -109,24 +107,6 @@ function filteredRecords(): ReviewRecord[] {
109
107
  })
110
108
  }
111
109
 
112
- function totalPages(): number {
113
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
114
- }
115
-
116
- function visibleRecords(): ReviewRecord[] {
117
- const safePage = Math.min(page(), totalPages())
118
- const offset = (safePage - 1) * pageSize
119
- return filteredRecords().slice(offset, offset + pageSize)
120
- }
121
-
122
- function rangeStart(): number {
123
- return filteredRecords().length === 0 ? 0 : (Math.min(page(), totalPages()) - 1) * pageSize + 1
124
- }
125
-
126
- function rangeEnd(): number {
127
- return Math.min(Math.min(page(), totalPages()) * pageSize, filteredRecords().length)
128
- }
129
-
130
110
  function metricCards(): Array<{ label: string, value: string }> {
131
111
  return [
132
112
  { label: 'Recorded', value: String(summary().total) },
@@ -137,14 +117,13 @@ function metricCards(): Array<{ label: string, value: string }> {
137
117
  ]
138
118
  }
139
119
 
140
- effect(() => {
141
- search()
142
- productFilter()
143
- ratingFilter()
144
- moderationFilter()
145
- sort()
146
- page.set(1)
147
- })
120
+ /**
121
+ * What the table is showing: a change returns it to the first page, as this
122
+ * page's own pager did before the table took over paging.
123
+ */
124
+ function viewKey(): string {
125
+ return JSON.stringify([search(), productFilter(), ratingFilter(), moderationFilter(), sort()])
126
+ }
148
127
 
149
128
  async function loadReviews(): Promise<void> {
150
129
  loading.set(true)
@@ -154,8 +133,6 @@ async function loadReviews(): Promise<void> {
154
133
  records.set(Array.isArray(data.records) ? data.records : [])
155
134
  summary.set(data.summary ?? { ...emptySummary })
156
135
  products.set(Array.isArray(data.products) ? data.products : [])
157
- if (page() > totalPages())
158
- page.set(totalPages())
159
136
  }
160
137
  catch (error) {
161
138
  loadError.set('Review records could not be loaded.')
@@ -335,7 +312,7 @@ onMount(() => {
335
312
 
336
313
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
337
314
  <p class="text-gray-500 text-sm dark:text-neutral-400">
338
- Showing {{ rangeStart() }} to {{ rangeEnd() }} of {{ filteredRecords().length }} reviews
315
+ {{ filteredRecords().length }} matching {{ filteredRecords().length === 1 ? 'review' : 'reviews' }}
339
316
  </p>
340
317
  <label class="flex gap-2 items-center text-gray-500 text-sm dark:text-neutral-400">
341
318
  Sort
@@ -350,7 +327,9 @@ onMount(() => {
350
327
  </div>
351
328
 
352
329
  <CommerceReviewsTable
353
- :reviews="visibleRecords()"
330
+ :sort-reset-key="sort()"
331
+ :page-reset-key="viewKey()"
332
+ :reviews="filteredRecords()"
354
333
  :loading="loading()"
355
334
  :busy-id="busyId()"
356
335
  @approve="toggleApproval"
@@ -359,11 +338,6 @@ onMount(() => {
359
338
  @delete="openDelete"
360
339
  />
361
340
 
362
- <nav :if="totalPages() > 1" aria-label="Review pages" class="flex items-center justify-between">
363
- <Button variant="secondary" :disabled="page() <= 1" @click="page.update(value => value - 1)">Previous</Button>
364
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ page() }} of {{ totalPages() }}</span>
365
- <Button variant="secondary" :disabled="page() >= totalPages()" @click="page.update(value => value + 1)">Next</Button>
366
- </nav>
367
341
  </section>
368
342
  </div>
369
343