@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
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.277",
5
+ "version": "0.75.280",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.277",
5
+ "version": "0.75.280",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -45,9 +45,9 @@
45
45
  "dependencies": {
46
46
  "@iconify-json/f7": "^1.2.2",
47
47
  "@iconify-json/hugeicons": "^1.2.35",
48
- "@stacksjs/mobile": "0.75.277",
48
+ "@stacksjs/mobile": "0.75.280",
49
49
  "@stacksjs/sanitizer": "0.2.669",
50
- "@stacksjs/stx": "0.2.670",
50
+ "@stacksjs/stx": "0.2.671",
51
51
  "@xterm/addon-fit": "^0.11.0",
52
52
  "@xterm/xterm": "^6.0.0",
53
53
  "ts-qr-codes": "^0.1.8"
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { AnalyticsRange, CampaignAnalyticsResponse } from '../../../../functions/analytics'
3
5
  import { fetchCampaignAnalytics } from '../../../../functions/analytics'
@@ -34,6 +36,23 @@ function campaigns(): CampaignAnalyticsResponse['campaigns'] {
34
36
  return analytics()?.campaigns || []
35
37
  }
36
38
 
39
+ /**
40
+ * Every campaign in the range, so this is the one table on the page that
41
+ * grows without bound: it pages. Counts the campaign did not record read
42
+ * "Not recorded" rather than 0, and spend is formatted in each campaign's own
43
+ * currency, never summed across them.
44
+ */
45
+ const CAMPAIGN_COLUMNS = [
46
+ { key: 'name', label: 'Campaign', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
47
+ { key: 'type', label: 'Channel', sortable: true },
48
+ { key: 'status', label: 'Status', sortable: true },
49
+ { key: 'sentCount', label: 'Sent', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
50
+ { key: 'opens', label: 'Opens', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
51
+ { key: 'clicks', label: 'Clicks', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
52
+ { key: 'conversions', label: 'Conversions', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
53
+ { key: 'spent', label: 'Spent', type: 'currency' as const, currencyKey: 'currency', sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
54
+ ]
55
+
37
56
  function channels(): CampaignAnalyticsResponse['channels'] {
38
57
  return analytics()?.channels || []
39
58
  }
@@ -141,36 +160,15 @@ onMount(() => {
141
160
  <h2 class="font-semibold text-gray-900 dark:text-white">Campaign performance</h2>
142
161
  <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Engagement counts are derived from each campaign's recorded sent count and rates.</p>
143
162
  </div>
144
- <div class="overflow-x-auto">
145
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
146
- <thead class="bg-gray-50 dark:bg-neutral-700/50">
147
- <tr>
148
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Campaign</th>
149
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Channel</th>
150
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Status</th>
151
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Sent</th>
152
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Opens</th>
153
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Clicks</th>
154
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Conversions</th>
155
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Spent</th>
156
- </tr>
157
- </thead>
158
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
159
- <template :for="campaign in campaigns()">
160
- <tr>
161
- <td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ campaign.name }}</td>
162
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ campaign.type }}</td>
163
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ campaign.status }}</td>
164
- <td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.sentCount) }}</td>
165
- <td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.opens) }}</td>
166
- <td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.clicks) }}</td>
167
- <td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.conversions) }}</td>
168
- <td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatMoney(campaign.spent, campaign.currency) }}</td>
169
- </tr>
170
- </template>
171
- </tbody>
172
- </table>
173
- </div>
163
+ <Table
164
+ :columns="CAMPAIGN_COLUMNS"
165
+ :data="campaigns()"
166
+ :loading="isLoading()"
167
+ paginated
168
+ :page-size="25"
169
+ empty-title="No campaigns in this period"
170
+ empty-description="Create and send campaigns from Marketing to populate this table."
171
+ />
174
172
  </section>
175
173
 
176
174
  <section class="grid gap-6 grid-cols-1 xl:grid-cols-2">
@@ -5,7 +5,6 @@ import type {
5
5
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
6
6
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
7
7
  import { pushToast } from '../../../../functions/toasts'
8
- import { pageControls } from '../../../../functions/dashboard-list'
9
8
 
10
9
  interface CategoryIndexResponse {
11
10
  records: CommerceCategoryRecord[]
@@ -29,8 +28,6 @@ const search = state('')
29
28
  const activeFilter = state('all')
30
29
  const hierarchyFilter = state('all')
31
30
  const sort = state('order')
32
- const page = state(1)
33
- const pageSize = 20
34
31
  const loading = state(true)
35
32
  const loadError = state('')
36
33
  const dialogOpen = state(false)
@@ -72,15 +69,6 @@ function filteredRecords(): CommerceCategoryRecord[] {
72
69
  })
73
70
  }
74
71
 
75
- function pageCount(): number {
76
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
77
- }
78
-
79
- function visibleRecords(): CommerceCategoryRecord[] {
80
- const selectedPage = Math.min(page(), pageCount())
81
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
82
- }
83
-
84
72
  function matchingLabel(): string {
85
73
  const count = filteredRecords().length
86
74
  return `${count} matching ${count === 1 ? 'category' : 'categories'}`
@@ -114,13 +102,13 @@ function exportHref(): string {
114
102
  return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
115
103
  }
116
104
 
117
- effect(() => {
118
- search()
119
- activeFilter()
120
- hierarchyFilter()
121
- sort()
122
- page.set(1)
123
- })
105
+ /**
106
+ * What the table is showing: a change returns it to the first page, as this
107
+ * page's own pager did before the table took over paging.
108
+ */
109
+ function viewKey(): string {
110
+ return JSON.stringify([search(), activeFilter(), hierarchyFilter(), sort()])
111
+ }
124
112
 
125
113
  async function loadCategories(): Promise<void> {
126
114
  loading.set(true)
@@ -129,7 +117,6 @@ async function loadCategories(): Promise<void> {
129
117
  const data = await dashboardApi<CategoryIndexResponse>('/api/dashboard/commerce/categories')
130
118
  records.set(Array.isArray(data.records) ? data.records : [])
131
119
  summary.set(data.summary ?? { ...emptySummary })
132
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
133
120
  }
134
121
  catch (error) {
135
122
  loadError.set('Product categories could not be loaded.')
@@ -231,8 +218,6 @@ async function deleteCategory(): Promise<void> {
231
218
  }
232
219
  }
233
220
 
234
- const { previousPage, nextPage } = pageControls(page, pageCount)
235
-
236
221
  onMount(() => {
237
222
  void loadCategories()
238
223
  })
@@ -267,14 +252,9 @@ onMount(() => {
267
252
 
268
253
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
269
254
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ matchingLabel() }}</p>
270
- <div class="flex gap-2 items-center">
271
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
272
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
273
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
274
- </div>
275
255
  </div>
276
256
 
277
- <CommerceCategoriesTable :records="visibleRecords()" :all-records="records()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
257
+ <CommerceCategoriesTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :all-records="records()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
278
258
  </section>
279
259
  </div>
280
260
 
@@ -1,43 +1,86 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { CommerceCategoryRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
3
5
 
4
6
  const records = useReactiveProp('records', [] as CommerceCategoryRecord[])
5
7
  const allRecords = useReactiveProp('allRecords', [] as CommerceCategoryRecord[])
6
8
  const loading = useReactiveProp('loading', false)
7
- const emit = defineEmits<{
8
- view: [record: CommerceCategoryRecord]
9
- edit: [record: CommerceCategoryRecord]
10
- delete: [record: CommerceCategoryRecord]
11
- }>()
9
+ /** The parent's sort select: a change hands the order back from a header sort. */
10
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
11
+ /** The parent's search and filters: a change returns to the first page. */
12
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
13
+ const emit = defineEmits()
12
14
 
13
15
  function parentName(record: CommerceCategoryRecord): string {
14
16
  if (!record.parentCategoryId)
15
17
  return 'Root category'
16
18
  return allRecords().find(parent => parent.id === record.parentCategoryId)?.name || 'Unknown parent'
17
19
  }
20
+
21
+ /**
22
+ * Only the category cell is slotted, for its initial. The parent comes from
23
+ * `allRecords`, which can hold categories the current filter hides, so it is
24
+ * resolved into the row rather than looked up per cell.
25
+ */
26
+ const COLUMNS = [
27
+ { key: 'name', label: 'Category', sortable: true, slotted: true },
28
+ { key: 'parentLabel', label: 'Hierarchy', sortable: true, subtitleKey: 'slug', class: 'text-gray-700 dark:text-neutral-200' },
29
+ { key: 'displayOrder', label: 'Display order', type: 'number' as const, sortable: true, class: 'font-mono' },
30
+ {
31
+ key: 'availability',
32
+ label: 'Availability',
33
+ type: 'badge' as const,
34
+ sortable: true,
35
+ badgeClasses: { active: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300' },
36
+ badgeLabels: { active: 'Active', inactive: 'Inactive' },
37
+ },
38
+ ]
39
+
40
+ const ROW_ACTIONS = [
41
+ { action: 'view', label: 'View', icon: 'i-hugeicons-view' },
42
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
43
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
44
+ ]
45
+
46
+ const rows = derived(() => records().map(record => ({
47
+ ...record,
48
+ initial: record.name.slice(0, 1).toUpperCase(),
49
+ summary: record.description || record.slug,
50
+ parentLabel: parentName(record),
51
+ availability: record.isActive ? 'active' : 'inactive',
52
+ })))
53
+
54
+ function handleRowAction(payload: { action: string, row: CommerceCategoryRecord }): void {
55
+ emit(payload.action, payload.row)
56
+ }
18
57
  </script>
19
58
 
20
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
21
- <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 categories</div>
22
- <div :if="!loading() && records().length === 0" class="py-16 text-center">
23
- <div class="mb-3 mx-auto h-8 w-8 text-gray-400 i-hugeicons-tags"></div>
24
- <p class="font-medium text-gray-900 text-sm dark:text-white">No product categories found</p>
25
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Adjust the filters or add a native Category record.</p>
26
- </div>
27
- <div :if="!loading() && records().length > 0" class="overflow-x-auto">
28
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
29
- <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">Category</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Hierarchy</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Display order</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"><span class="sr-only">Actions</span></th></tr></thead>
30
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
31
- <template :for="record in records()" :key="record.id">
32
- <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
33
- <td class="px-4 py-3"><div class="flex gap-3 items-center"><span class="flex items-center justify-center shrink-0 h-9 w-9 font-semibold text-blue-700 text-sm dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md">{{ record.name.slice(0, 1).toUpperCase() }}</span><div class="min-w-0"><Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.name }}</Button><p class="mt-1 max-w-96 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.description || record.slug }}</p></div></div></td>
34
- <td class="px-4 py-3"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ parentName(record) }}</p><p class="mt-1 font-mono text-gray-400 text-xs dark:text-neutral-500">{{ record.slug }}</p></td>
35
- <td class="px-4 py-3 font-mono text-gray-700 text-sm dark:text-neutral-200">{{ record.displayOrder }}</td>
36
- <td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (record.isActive ? 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300')">{{ record.isActive ? 'Active' : 'Inactive' }}</span></td>
37
- <td class="px-4 py-3"><div class="flex gap-1 justify-end"><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><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-02"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.name" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td>
38
- </tr>
39
- </template>
40
- </tbody>
41
- </table>
42
- </div>
43
- </div>
59
+ {{--
60
+ No `searchable` or `filterable`: the parent dashboard owns the search box
61
+ and the filter selects. Table pages the whole filtered list the parent passes.
62
+ --}}
63
+ <Table
64
+ :sort-reset-key="sortResetKey()"
65
+ :page-reset-key="pageResetKey()"
66
+ :columns="COLUMNS"
67
+ :data="rows()"
68
+ :loading="loading()"
69
+ :row-actions="ROW_ACTIONS"
70
+ action-label-key="name"
71
+ paginated
72
+ :page-size="20"
73
+ empty-title="No product categories found"
74
+ empty-description="Adjust the filters or add a native Category record."
75
+ @row-action="handleRowAction"
76
+ >
77
+ <template #cell="{ row }">
78
+ <div class="flex gap-3 items-center">
79
+ <span class="flex items-center justify-center shrink-0 h-9 w-9 font-semibold text-blue-700 text-sm dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md" x-text="row.initial"></span>
80
+ <div class="min-w-0">
81
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.name"></p>
82
+ <p class="mt-1 max-w-96 text-gray-400 text-xs truncate dark:text-neutral-500" x-text="row.summary"></p>
83
+ </div>
84
+ </div>
85
+ </template>
86
+ </Table>
@@ -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 CouponIndexResponse {
14
13
  records: CouponRecord[]
@@ -38,8 +37,6 @@ const statusFilter = state('all')
38
37
  const enabledFilter = state('all')
39
38
  const typeFilter = state('all')
40
39
  const sort = state('newest')
41
- const page = state(1)
42
- const pageSize = 20
43
40
  const loading = state(true)
44
41
  const loadError = state('')
45
42
  const dialogOpen = state(false)
@@ -80,15 +77,6 @@ function filteredRecords(): CouponRecord[] {
80
77
  })
81
78
  }
82
79
 
83
- function pageCount(): number {
84
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
85
- }
86
-
87
- function visibleRecords(): CouponRecord[] {
88
- const selectedPage = Math.min(page(), pageCount())
89
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
90
- }
91
-
92
80
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
93
81
  const current = summary()
94
82
  return [
@@ -121,14 +109,13 @@ function exportHref(): string {
121
109
  return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
122
110
  }
123
111
 
124
- effect(() => {
125
- search()
126
- statusFilter()
127
- enabledFilter()
128
- typeFilter()
129
- sort()
130
- page.set(1)
131
- })
112
+ /**
113
+ * What the table is showing: a change returns it to the first page, as this
114
+ * page's own pager did before the table took over paging.
115
+ */
116
+ function viewKey(): string {
117
+ return JSON.stringify([search(), statusFilter(), enabledFilter(), typeFilter(), sort()])
118
+ }
132
119
 
133
120
  async function loadCoupons(): Promise<void> {
134
121
  loading.set(true)
@@ -137,7 +124,6 @@ async function loadCoupons(): Promise<void> {
137
124
  const data = await dashboardApi<CouponIndexResponse>('/api/dashboard/commerce/coupons')
138
125
  records.set(Array.isArray(data.records) ? data.records : [])
139
126
  summary.set(data.summary ?? { ...emptySummary })
140
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
141
127
  }
142
128
  catch (error) {
143
129
  loadError.set('Coupons could not be loaded.')
@@ -242,8 +228,6 @@ async function deleteCoupon(): Promise<void> {
242
228
  }
243
229
  }
244
230
 
245
- const { previousPage, nextPage } = pageControls(page, pageCount)
246
-
247
231
  onMount(() => {
248
232
  void loadCoupons()
249
233
  })
@@ -279,14 +263,9 @@ onMount(() => {
279
263
 
280
264
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
281
265
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ filteredRecords().length }} matching coupons</p>
282
- <div class="flex gap-2 items-center">
283
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
284
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
285
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
286
- </div>
287
266
  </div>
288
267
 
289
- <CommerceCouponsTable :records="visibleRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
268
+ <CommerceCouponsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
290
269
  </section>
291
270
  </div>
292
271
 
@@ -1,20 +1,18 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { CouponRecord } from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
3
5
  import { formatCurrency } from '@stacksjs/commerce/money'
4
6
 
5
7
  const records = useReactiveProp('records', [] as CouponRecord[])
6
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', '')
11
+ /** The parent's search and filters: a change returns to the first page. */
12
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
7
13
  const currency = useReactiveProp('currency', 'USD')
8
14
  const emit = defineEmits()
9
15
 
10
- function statusClass(status: string): string {
11
- if (status === 'Active')
12
- return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300'
13
- if (status === 'Scheduled')
14
- return 'bg-blue-100 text-blue-800 dark:bg-blue-950/50 dark:text-blue-300'
15
- return 'bg-gray-100 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300'
16
- }
17
-
18
16
  // A percentage is a rate, not money; a fixed amount is integer minor units.
19
17
  function discount(record: CouponRecord): string {
20
18
  return record.discountType === 'percentage'
@@ -22,47 +20,69 @@ function discount(record: CouponRecord): string {
22
20
  : formatCurrency(record.discountValue, currency())
23
21
  }
24
22
 
25
- function date(value: string): string {
26
- if (!value)
27
- return 'No date'
28
- const parsed = new Date(`${value}T00:00:00`)
29
- return Number.isNaN(parsed.getTime())
30
- ? value
31
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(parsed)
23
+ const COLUMNS = [
24
+ { key: 'codeLabel', label: 'Coupon', sortable: true, subtitleKey: 'descriptionLabel', class: 'font-medium text-gray-900 dark:text-white' },
25
+ { key: 'discountLabel', label: 'Discount', subtitleKey: 'discountKind', searchable: false, class: 'font-semibold text-gray-700 whitespace-nowrap dark:text-neutral-200' },
26
+ {
27
+ key: 'status',
28
+ label: 'Status',
29
+ type: 'badge' as const,
30
+ sortable: true,
31
+ badgeClasses: {
32
+ Active: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300',
33
+ Scheduled: 'bg-blue-100 text-blue-800 dark:bg-blue-950/50 dark:text-blue-300',
34
+ },
35
+ },
36
+ {
37
+ key: 'availability',
38
+ label: 'Availability',
39
+ type: 'badge' as const,
40
+ sortable: true,
41
+ badgeClasses: { enabled: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40' },
42
+ badgeLabels: { enabled: 'Enabled', disabled: 'Disabled' },
43
+ },
44
+ { key: 'usageLabel', label: 'Usage', align: 'right' as const, searchable: false, class: 'font-mono whitespace-nowrap' },
45
+ // Calendar dates: parseDashboardDate reads them as that day in every zone.
46
+ { key: 'startDate', label: 'Starts', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'whitespace-nowrap' },
47
+ { key: 'endDate', label: 'Ends', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'whitespace-nowrap' },
48
+ ]
49
+
50
+ const ROW_ACTIONS = [
51
+ { action: 'view', label: 'View', icon: 'i-hugeicons-view' },
52
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
53
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
54
+ ]
55
+
56
+ const rows = derived(() => records().map(record => ({
57
+ ...record,
58
+ codeLabel: record.code || 'No code',
59
+ descriptionLabel: record.description || 'No description recorded',
60
+ discountLabel: discount(record),
61
+ discountKind: record.discountType === 'percentage' ? 'Percentage' : 'Fixed amount',
62
+ availability: record.isActive ? 'enabled' : 'disabled',
63
+ usageLabel: `${record.usageCount.toLocaleString()} / ${record.usageLimit ? record.usageLimit.toLocaleString() : 'No limit'}`,
64
+ })))
65
+
66
+ function handleRowAction(payload: { action: string, row: CouponRecord }): void {
67
+ emit(payload.action, payload.row)
32
68
  }
33
69
  </script>
34
70
 
35
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
36
- <div :if="loading() && records().length === 0" class="flex items-center justify-center p-10 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 coupons</div>
37
- <div :else-if="records().length === 0" class="p-10 text-center"><p class="font-medium text-gray-900 text-sm dark:text-white">No coupons found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Add a coupon or adjust the current filters.</p></div>
38
- <div :else class="overflow-x-auto">
39
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
40
- <thead class="bg-gray-50 dark:bg-neutral-900/50"><tr>
41
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Coupon</th>
42
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Discount</th>
43
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Status</th>
44
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Availability</th>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs uppercase dark:text-neutral-400">Usage</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Dates</th>
47
- <th scope="col" class="relative px-4 py-3"><span class="sr-only">Actions</span></th>
48
- </tr></thead>
49
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
50
- <template :for="record in records()" :key="record.id">
51
- <tr class="hover:bg-gray-50/60 dark:hover:bg-neutral-700/40">
52
- <td class="px-4 py-4 min-w-56"><Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.code || 'No code' }}</Button><p class="mt-1 max-w-xs text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.description || 'No description recorded' }}</p></td>
53
- <td class="px-4 py-4 text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200"><span class="font-semibold">{{ discount(record) }}</span><p class="mt-1 text-gray-400 text-xs">{{ record.discountType === 'percentage' ? 'Percentage' : 'Fixed amount' }}</p></td>
54
- <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)">{{ record.status }}</span></td>
55
- <td class="px-4 py-4 text-sm whitespace-nowrap"><span :class="record.isActive ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-neutral-400'">{{ record.isActive ? 'Enabled' : 'Disabled' }}</span></td>
56
- <td class="px-4 py-4 font-mono text-gray-700 text-right text-sm whitespace-nowrap dark:text-neutral-200">{{ record.usageCount.toLocaleString() }} / {{ record.usageLimit ? record.usageLimit.toLocaleString() : 'No limit' }}</td>
57
- <td class="px-4 py-4 min-w-44 text-gray-500 text-sm dark:text-neutral-400"><p>{{ date(record.startDate) }}</p><p class="mt-1 text-xs">to {{ date(record.endDate) }}</p></td>
58
- <td class="px-4 py-3 text-right whitespace-nowrap">
59
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'View ' + record.code" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button>
60
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + record.code" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button>
61
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.code" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span></Button>
62
- </td>
63
- </tr>
64
- </template>
65
- </tbody>
66
- </table>
67
- </div>
68
- </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="codeLabel"
83
+ paginated
84
+ :page-size="20"
85
+ empty-title="No coupons found"
86
+ empty-description="Add a coupon or adjust the current filters."
87
+ @row-action="handleRowAction"
88
+ />
@@ -6,7 +6,6 @@ import type {
6
6
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
7
7
  import { pushToast } from '../../../../functions/toasts'
8
8
  import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
9
- import { pageControls } from '../../../../functions/dashboard-list'
10
9
 
11
10
  interface CustomerIndexResponse {
12
11
  records: CommerceCustomerRecord[]
@@ -37,8 +36,6 @@ const currency = state('USD')
37
36
  const search = state('')
38
37
  const statusFilter = state('all')
39
38
  const sort = state('name')
40
- const page = state(1)
41
- const pageSize = 15
42
39
  const loading = state(true)
43
40
  const loadError = state('')
44
41
  const dialogOpen = state(false)
@@ -85,15 +82,6 @@ function filteredRecords(): CommerceCustomerRecord[] {
85
82
  })
86
83
  }
87
84
 
88
- function pageCount(): number {
89
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
90
- }
91
-
92
- function visibleRecords(): CommerceCustomerRecord[] {
93
- const selectedPage = Math.min(page(), pageCount())
94
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
95
- }
96
-
97
85
  function matchingLabel(): string {
98
86
  const count = filteredRecords().length
99
87
  return `${count} matching ${count === 1 ? 'customer' : 'customers'}`
@@ -127,12 +115,13 @@ function exportHref(): string {
127
115
  return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
128
116
  }
129
117
 
130
- effect(() => {
131
- search()
132
- statusFilter()
133
- sort()
134
- page.set(1)
135
- })
118
+ /**
119
+ * What the table is showing: a change returns it to the first page, as this
120
+ * page's own pager did before the table took over paging.
121
+ */
122
+ function viewKey(): string {
123
+ return JSON.stringify([search(), statusFilter(), sort()])
124
+ }
136
125
 
137
126
  async function loadCustomers(): Promise<void> {
138
127
  loading.set(true)
@@ -142,7 +131,6 @@ async function loadCustomers(): Promise<void> {
142
131
  records.set(Array.isArray(data.records) ? data.records : [])
143
132
  summary.set(data.summary ?? { ...emptySummary })
144
133
  currency.set(data.currency || 'USD')
145
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
146
134
  }
147
135
  catch (error) {
148
136
  loadError.set('Customer records could not be loaded.')
@@ -244,8 +232,6 @@ async function deleteCustomer(): Promise<void> {
244
232
  }
245
233
  }
246
234
 
247
- const { previousPage, nextPage } = pageControls(page, pageCount)
248
-
249
235
  onMount(() => {
250
236
  void loadCustomers()
251
237
  })
@@ -277,14 +263,9 @@ onMount(() => {
277
263
 
278
264
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
279
265
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ matchingLabel() }}</p>
280
- <div class="flex gap-2 items-center">
281
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
282
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
283
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
284
- </div>
285
266
  </div>
286
267
 
287
- <CommerceCustomersTable :records="visibleRecords()" :loading="loading()" :currency="currency()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
268
+ <CommerceCustomersTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" :currency="currency()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
288
269
  </section>
289
270
  </div>
290
271