@stacksjs/defaults 0.75.279 → 0.75.281

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 (60) hide show
  1. package/ai/skills/stacks-database/SKILL.md +1 -3
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ide/vscode/package.json +1 -1
  4. package/package.json +3 -3
  5. package/project/bun-version +1 -1
  6. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
  7. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
  8. package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
  9. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
  10. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
  11. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
  12. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
  13. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
  14. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
  15. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
  16. package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
  17. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
  18. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
  19. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  20. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
  21. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
  22. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
  23. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
  24. package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
  25. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
  26. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
  27. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
  28. package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
  29. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
  30. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
  31. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
  32. package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
  33. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
  34. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
  35. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
  36. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
  37. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
  38. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
  42. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
  43. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
  44. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
  45. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
  46. package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
  47. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
  48. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
  49. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
  50. package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
  51. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
  52. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
  53. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
  54. package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
  55. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
  56. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
  57. package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
  58. package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
  59. package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
  60. package/resources/components/Dashboard/UI/Table.stx +41 -2
@@ -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
 
@@ -1,61 +1,83 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { CommerceCustomerRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
3
- import { formatCurrency } from '@stacksjs/commerce/money'
4
5
 
5
6
  const records = useReactiveProp('records', [] as CommerceCustomerRecord[])
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
 
10
- function formatMoney(value: number): string {
11
- return formatCurrency(value, currency())
12
- }
15
+ /** Only the customer cell is slotted, for its initial. */
16
+ const COLUMNS = [
17
+ { key: 'nameLabel', label: 'Customer', sortable: true, slotted: true },
18
+ { key: 'emailLabel', label: 'Contact', sortable: true, subtitleKey: 'phoneLabel', class: 'text-gray-700 dark:text-neutral-200' },
19
+ // Money is integer minor units; the row carries the store currency so the
20
+ // column formats it through formatCurrency, not as a bare number.
21
+ { key: 'totalSpent', label: 'Recorded spend', type: 'currency' as const, currencyKey: 'currency', sortable: true, class: 'font-medium' },
22
+ { key: 'lastOrder', label: 'Last order', type: 'date' as const, sortable: true, emptyText: 'No order recorded', class: 'whitespace-nowrap' },
23
+ {
24
+ key: 'status',
25
+ label: 'Status',
26
+ type: 'badge' as const,
27
+ sortable: true,
28
+ badgeClasses: {
29
+ Active: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
30
+ Inactive: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300',
31
+ },
32
+ },
33
+ ]
13
34
 
14
- function formatDate(value: string): string {
15
- if (!value)
16
- return 'No order recorded'
17
- const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
18
- ? `${value}T00:00:00`
19
- : /^\d{4}-\d{2}-\d{2} \d/.test(value)
20
- ? `${value.replace(' ', 'T')}Z`
21
- : value
22
- const date = new Date(normalized)
23
- return Number.isNaN(date.getTime())
24
- ? value
25
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
26
- }
35
+ const ROW_ACTIONS = [
36
+ { action: 'view', label: 'View', icon: 'i-hugeicons-view' },
37
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
38
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
39
+ ]
40
+
41
+ const rows = derived(() => records().map(record => ({
42
+ ...record,
43
+ currency: currency(),
44
+ nameLabel: record.name || 'Unnamed customer',
45
+ initial: (record.name || '?').slice(0, 1).toUpperCase(),
46
+ idLabel: `ID ${record.id || 'not recorded'}`,
47
+ emailLabel: record.email || 'No email recorded',
48
+ phoneLabel: record.phone || 'No phone recorded',
49
+ })))
27
50
 
28
- function statusClass(record: CommerceCustomerRecord): string {
29
- if (record.status === 'Active')
30
- return 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300'
31
- if (record.status === 'Inactive')
32
- return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
33
- return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
51
+ function handleRowAction(payload: { action: string, row: CommerceCustomerRecord }): void {
52
+ emit(payload.action, payload.row)
34
53
  }
35
54
  </script>
36
55
 
37
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
38
- <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 customers</div>
39
- <div :if="!loading() && records().length === 0" class="py-16 text-center">
40
- <div class="mb-3 mx-auto h-8 w-8 text-gray-400 i-hugeicons-user-group"></div>
41
- <p class="font-medium text-gray-900 text-sm dark:text-white">No customers found</p>
42
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Adjust the filters or add a native Customer record.</p>
43
- </div>
44
- <div :if="!loading() && records().length > 0" class="overflow-x-auto">
45
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
46
- <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">Customer</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Contact</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Recorded spend</th><th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Last order</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"><span class="sr-only">Actions</span></th></tr></thead>
47
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
48
- <template :for="record in records()" :key="record.id">
49
- <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
50
- <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 || 'Unnamed customer' }}</Button><p class="mt-1 font-mono text-gray-400 text-xs dark:text-neutral-500">ID {{ record.id || 'not recorded' }}</p></div></div></td>
51
- <td class="px-4 py-3"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.email || 'No email recorded' }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.phone || 'No phone recorded' }}</p></td>
52
- <td class="px-4 py-3 font-medium text-gray-700 text-sm dark:text-neutral-200">{{ formatMoney(record.totalSpent) }}</td>
53
- <td class="px-4 py-3 text-gray-700 text-sm dark:text-neutral-200">{{ formatDate(record.lastOrder) }}</td>
54
- <td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record)">{{ record.status }}</span></td>
55
- <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>
56
- </tr>
57
- </template>
58
- </tbody>
59
- </table>
60
- </div>
61
- </div>
56
+ {{--
57
+ No `searchable` or `filterable`: the parent dashboard owns the search box
58
+ and the filter selects. Table pages the whole filtered list the parent passes.
59
+ --}}
60
+ <Table
61
+ :sort-reset-key="sortResetKey()"
62
+ :page-reset-key="pageResetKey()"
63
+ :columns="COLUMNS"
64
+ :data="rows()"
65
+ :loading="loading()"
66
+ :row-actions="ROW_ACTIONS"
67
+ action-label-key="nameLabel"
68
+ paginated
69
+ :page-size="15"
70
+ empty-title="No customers found"
71
+ empty-description="Adjust the filters or add a native Customer record."
72
+ @row-action="handleRowAction"
73
+ >
74
+ <template #cell="{ row }">
75
+ <div class="flex gap-3 items-center">
76
+ <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>
77
+ <div class="min-w-0">
78
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.nameLabel"></p>
79
+ <p class="mt-1 font-mono text-gray-400 text-xs dark:text-neutral-500" x-text="row.idLabel"></p>
80
+ </div>
81
+ </div>
82
+ </template>
83
+ </Table>
@@ -7,7 +7,6 @@ import type {
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
9
  import { minorToInput } from '@stacksjs/commerce/money'
10
- import { pageControls } from '../../../../functions/dashboard-list'
11
10
 
12
11
  interface GiftCardIndexResponse {
13
12
  records: GiftCardRecord[]
@@ -49,8 +48,6 @@ const statusFilter = state('all')
49
48
  const enabledFilter = state('all')
50
49
  const deliveryFilter = state('all')
51
50
  const sort = state('newest')
52
- const page = state(1)
53
- const pageSize = 20
54
51
  const loading = state(true)
55
52
  const loadError = state('')
56
53
  const dialogOpen = state(false)
@@ -98,15 +95,6 @@ function filteredRecords(): GiftCardRecord[] {
98
95
  })
99
96
  }
100
97
 
101
- function pageCount(): number {
102
- return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
103
- }
104
-
105
- function visibleRecords(): GiftCardRecord[] {
106
- const selectedPage = Math.min(page(), pageCount())
107
- return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
108
- }
109
-
110
98
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
111
99
  const current = summary()
112
100
  return [
@@ -140,14 +128,13 @@ function exportHref(): string {
140
128
  return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
141
129
  }
142
130
 
143
- effect(() => {
144
- search()
145
- statusFilter()
146
- enabledFilter()
147
- deliveryFilter()
148
- sort()
149
- page.set(1)
150
- })
131
+ /**
132
+ * What the table is showing: a change returns it to the first page, as this
133
+ * page's own pager did before the table took over paging.
134
+ */
135
+ function viewKey(): string {
136
+ return JSON.stringify([search(), statusFilter(), enabledFilter(), deliveryFilter(), sort()])
137
+ }
151
138
 
152
139
  async function loadGiftCards(): Promise<void> {
153
140
  loading.set(true)
@@ -156,7 +143,6 @@ async function loadGiftCards(): Promise<void> {
156
143
  const data = await dashboardApi<GiftCardIndexResponse>('/api/dashboard/commerce/gift-cards')
157
144
  records.set(Array.isArray(data.records) ? data.records : [])
158
145
  summary.set(data.summary ?? { ...emptySummary })
159
- page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
160
146
  }
161
147
  catch (error) {
162
148
  loadError.set('Gift cards could not be loaded.')
@@ -258,8 +244,6 @@ async function deleteGiftCard(): Promise<void> {
258
244
  }
259
245
  }
260
246
 
261
- const { previousPage, nextPage } = pageControls(page, pageCount)
262
-
263
247
  onMount(() => {
264
248
  void loadGiftCards()
265
249
  })
@@ -295,14 +279,9 @@ onMount(() => {
295
279
 
296
280
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
297
281
  <p class="text-gray-500 text-sm dark:text-neutral-400">{{ filteredRecords().length }} matching gift cards</p>
298
- <div class="flex gap-2 items-center">
299
- <Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
300
- <span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
301
- <Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
302
- </div>
303
282
  </div>
304
283
 
305
- <CommerceGiftCardsTable :records="visibleRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
284
+ <CommerceGiftCardsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
306
285
  </section>
307
286
  </div>
308
287
 
@@ -1,94 +1,84 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { GiftCardRecord, GiftCardStatus } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
5
  import { formatCurrency } from '@stacksjs/commerce/money'
4
6
 
5
- const { records = [], loading = false } = defineProps<{ records: GiftCardRecord[], loading?: boolean }>()
6
- const emit = defineEmits<{
7
- view: [record: GiftCardRecord]
8
- edit: [record: GiftCardRecord]
9
- delete: [record: GiftCardRecord]
10
- }>()
11
-
12
- function money(value: number, currency: string): string {
13
- return formatCurrency(value, currency)
14
- }
15
-
16
- function date(value: string): string {
17
- if (!value)
18
- return 'No expiry'
19
- const parsed = new Date(value)
20
- return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
21
- }
7
+ const records = useReactiveProp('records', [] as GiftCardRecord[])
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', '')
13
+ const emit = defineEmits()
22
14
 
23
15
  function statusLabel(status: GiftCardStatus): string {
24
16
  return status.charAt(0) + status.slice(1).toLowerCase()
25
17
  }
26
18
 
27
- function statusClass(status: GiftCardStatus): string {
28
- if (status === 'ACTIVE')
29
- return 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300'
30
- if (status === 'USED')
31
- return 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'
32
- if (status === 'EXPIRED')
33
- return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
34
- return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
19
+ /**
20
+ * The balance is a currency column keyed on each card's own currency; the
21
+ * initial balance beneath it is formatted here with the same helper, since
22
+ * a subtitle is text.
23
+ */
24
+ const COLUMNS = [
25
+ { key: 'code', label: 'Gift card', sortable: true, subtitleKey: 'currency', class: 'font-medium text-gray-900 dark:text-white' },
26
+ { key: 'recipientLabel', label: 'Recipient', sortable: true, subtitleKey: 'recipientEmailLabel', class: 'text-gray-900 dark:text-white' },
27
+ { key: 'currentBalance', label: 'Balance', type: 'currency' as const, currencyKey: 'currency', sortable: true, subtitleKey: 'initialLabel', class: 'font-medium text-gray-900 dark:text-white' },
28
+ {
29
+ key: 'status',
30
+ label: 'Status',
31
+ type: 'badge' as const,
32
+ sortable: true,
33
+ badgeLabelKey: 'statusLabel',
34
+ subtitleKey: 'enabledLabel',
35
+ badgeClasses: {
36
+ ACTIVE: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
37
+ USED: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
38
+ EXPIRED: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
39
+ },
40
+ },
41
+ { key: 'deliveryLabel', label: 'Delivery', sortable: true, subtitleKey: 'reloadLabel', class: 'text-gray-700 dark:text-neutral-200' },
42
+ { key: 'expiryDate', label: 'Expiry', type: 'date' as const, sortable: true, emptyText: 'No expiry', class: 'whitespace-nowrap' },
43
+ ]
44
+
45
+ const ROW_ACTIONS = [
46
+ { action: 'view', label: 'View', icon: 'i-hugeicons-view' },
47
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
48
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
49
+ ]
50
+
51
+ const rows = derived(() => records().map(record => ({
52
+ ...record,
53
+ recipientLabel: record.recipientName || 'Unassigned',
54
+ recipientEmailLabel: record.recipientEmail || 'No recipient email',
55
+ initialLabel: `of ${formatCurrency(record.initialBalance, record.currency)}`,
56
+ statusLabel: statusLabel(record.status),
57
+ enabledLabel: record.isActive ? 'Enabled' : 'Disabled',
58
+ deliveryLabel: record.isDigital ? 'Digital' : 'Physical',
59
+ reloadLabel: record.isReloadable ? 'Reloadable' : 'Single use',
60
+ })))
61
+
62
+ function handleRowAction(payload: { action: string, row: GiftCardRecord }): void {
63
+ emit(payload.action, payload.row)
35
64
  }
36
65
  </script>
37
66
 
38
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
39
- <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 gift cards</div>
40
- <div :if="!loading && records.length === 0" class="py-16 text-center">
41
- <div class="mb-3 mx-auto h-8 w-8 text-gray-400 i-hugeicons-gift"></div>
42
- <p class="font-medium text-gray-900 text-sm dark:text-white">No gift cards found</p>
43
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Adjust the filters or add a native GiftCard record.</p>
44
- </div>
45
- <div :if="!loading && records.length > 0" class="overflow-x-auto">
46
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
47
- <thead class="bg-gray-50 dark:bg-neutral-900/60">
48
- <tr>
49
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Gift card</th>
50
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Recipient</th>
51
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Balance</th>
52
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Status</th>
53
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Delivery</th>
54
- <th class="px-4 py-3 font-semibold text-gray-500 text-left text-xs tracking-wide uppercase dark:text-neutral-400">Expiry</th>
55
- <th class="px-4 py-3"><span class="sr-only">Actions</span></th>
56
- </tr>
57
- </thead>
58
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
59
- <template :for="record in records" :key="record.id">
60
- <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
61
- <td class="px-4 py-3">
62
- <Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.code }}</Button>
63
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.currency }}</p>
64
- </td>
65
- <td class="px-4 py-3">
66
- <p class="text-gray-900 text-sm dark:text-white">{{ record.recipientName || 'Unassigned' }}</p>
67
- <p class="mt-1 max-w-52 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.recipientEmail || 'No recipient email' }}</p>
68
- </td>
69
- <td class="px-4 py-3">
70
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ money(record.currentBalance, record.currency) }}</p>
71
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">of {{ money(record.initialBalance, record.currency) }}</p>
72
- </td>
73
- <td class="px-4 py-3">
74
- <span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record.status)">{{ statusLabel(record.status) }}</span>
75
- <p :class="'mt-1 text-xs ' + (record.isActive ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-neutral-500')">{{ record.isActive ? 'Enabled' : 'Disabled' }}</p>
76
- </td>
77
- <td class="px-4 py-3">
78
- <p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.isDigital ? 'Digital' : 'Physical' }}</p>
79
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.isReloadable ? 'Reloadable' : 'Single use' }}</p>
80
- </td>
81
- <td class="px-4 py-3 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ date(record.expiryDate) }}</td>
82
- <td class="px-4 py-3">
83
- <div class="flex gap-1 justify-end">
84
- <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>
85
- <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-02"></span></Button>
86
- <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-delete-02"></span></Button>
87
- </div>
88
- </td>
89
- </tr>
90
- </template>
91
- </tbody>
92
- </table>
93
- </div>
94
- </div>
67
+ {{--
68
+ No `searchable` or `filterable`: the parent dashboard owns the search box
69
+ and the filter selects. Table pages the whole filtered list the parent passes.
70
+ --}}
71
+ <Table
72
+ :sort-reset-key="sortResetKey()"
73
+ :page-reset-key="pageResetKey()"
74
+ :columns="COLUMNS"
75
+ :data="rows()"
76
+ :loading="loading()"
77
+ :row-actions="ROW_ACTIONS"
78
+ action-label-key="code"
79
+ paginated
80
+ :page-size="20"
81
+ empty-title="No gift cards found"
82
+ empty-description="Adjust the filters or add a native GiftCard record."
83
+ @row-action="handleRowAction"
84
+ />
@@ -233,7 +233,7 @@ onMount(() => {
233
233
  </label>
234
234
  </div>
235
235
 
236
- <CommerceManufacturersTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
236
+ <CommerceManufacturersTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
237
237
  </section>
238
238
  </div>
239
239