@stacksjs/defaults 0.75.231 → 0.75.236

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 (75) hide show
  1. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  2. package/ai/skills/stacks-database/SKILL.md +1 -1
  3. package/ai/skills/stacks-deploy/SKILL.md +3 -2
  4. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  5. package/ai/skills/stacks-native/CATALOG.md +10 -10
  6. package/ai/skills/stacks-realtime/SKILL.md +4 -2
  7. package/ai/skills/stacks-storage/SKILL.md +43 -0
  8. package/ai/skills/stacks-testing/SKILL.md +38 -10
  9. package/app/Actions/Monitoring/error-provider.ts +129 -12
  10. package/app/Models/Error.ts +21 -0
  11. package/functions/dashboard-form.test.ts +41 -0
  12. package/functions/dashboard-form.ts +30 -0
  13. package/functions/dashboard-list.test.ts +228 -0
  14. package/functions/dashboard-list.ts +257 -0
  15. package/ide/vscode/package.json +1 -1
  16. package/package.json +2 -2
  17. package/project/storage/framework/types/browser-composables.d.ts +56 -0
  18. package/resources/components/Dashboard/Auth/AuthCard.stx +29 -0
  19. package/resources/components/Dashboard/Auth/ForgotPassword.stx +65 -79
  20. package/resources/components/Dashboard/Auth/Login.stx +73 -82
  21. package/resources/components/Dashboard/Auth/Register.stx +121 -135
  22. package/resources/components/Dashboard/Auth/ResetPassword.stx +88 -102
  23. package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +11 -15
  24. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +10 -27
  25. package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +2 -4
  26. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +2 -4
  27. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +10 -27
  28. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +2 -4
  29. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +10 -27
  30. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +2 -4
  31. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +10 -27
  32. package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +2 -4
  33. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +13 -24
  34. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +2 -4
  35. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +10 -27
  36. package/resources/components/Dashboard/Commerce/CommerceOverviewDashboard.stx +17 -21
  37. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +7 -19
  38. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +4 -16
  39. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +2 -4
  40. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +10 -27
  41. package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +2 -4
  42. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +7 -18
  43. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +2 -4
  44. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +13 -24
  45. package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +2 -4
  46. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +13 -24
  47. package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +2 -4
  48. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +7 -18
  49. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +52 -105
  50. package/resources/components/Dashboard/Commerce/Delivery/DeliveryOverviewDashboard.stx +12 -16
  51. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +53 -105
  52. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +7 -13
  53. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +53 -105
  54. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +7 -13
  55. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +51 -103
  56. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +52 -104
  57. package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +2 -4
  58. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +10 -27
  59. package/resources/components/Dashboard/Commerce/PrintLogsDashboard.stx +7 -19
  60. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +12 -16
  61. package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +2 -4
  62. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +12 -16
  63. package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +2 -4
  64. package/resources/components/Dashboard/Deployments/DeploymentList.stx +17 -21
  65. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +8 -19
  66. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +8 -19
  67. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +11 -22
  68. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +8 -19
  69. package/resources/components/Dashboard/Queue/QueueDashboard.stx +12 -16
  70. package/resources/components/Dashboard/Realtime/RealtimeDashboard.stx +6 -10
  71. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +6 -10
  72. package/resources/components/Dashboard/UI/MetricCards.stx +32 -0
  73. package/resources/components/Dashboard/UI/PageHeader.stx +25 -66
  74. package/resources/components/Dashboard/UI/Table.stx +79 -6
  75. package/resources/plugins/preloader.ts +9 -1
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import { dashboardApi } from '../../../../../functions/dashboard-api'
3
+ import { useRecordsDashboard } from '../../../../../functions/dashboard-list'
3
4
  import { pushToast } from '../../../../../functions/toasts'
4
5
 
5
6
  type SerializedList = string | string[] | null | undefined
@@ -30,21 +31,47 @@ interface ShippingZonePayload {
30
31
  status: 'active' | 'inactive' | 'draft'
31
32
  }
32
33
 
33
- const records = state<ShippingZoneRecord[]>([])
34
+ function matchesQuery(records: ShippingZoneRecord[], query: string): ShippingZoneRecord[] {
35
+ return records.filter(record => [
36
+ record.name,
37
+ record.status,
38
+ record.shipping_method?.name,
39
+ ...listTokens(record.countries),
40
+ ...listTokens(record.regions),
41
+ ...listTokens(record.postal_codes),
42
+ ].some(value => String(value || '').toLowerCase().includes(query)))
43
+ }
44
+
45
+ const {
46
+ records,
47
+ loading,
48
+ loadError,
49
+ search,
50
+ page,
51
+ perPage,
52
+ editorOpen,
53
+ editorMode,
54
+ editing,
55
+ saving,
56
+ editorError,
57
+ deleting,
58
+ deleteBusy,
59
+ deleteError,
60
+ filteredRecords,
61
+ paginatedRecords,
62
+ goToPage,
63
+ handleSearch,
64
+ openCreate,
65
+ openEdit,
66
+ closeEditor,
67
+ requestDelete,
68
+ closeDelete,
69
+ upsert,
70
+ remove,
71
+ clampPage,
72
+ } = useRecordsDashboard<ShippingZoneRecord>({ filter: matchesQuery })
73
+
34
74
  const methods = state<ShippingMethodOption[]>([])
35
- const loading = state(true)
36
- const loadError = state('')
37
- const search = state('')
38
- const page = state(1)
39
- const perPage = state(10)
40
- const editorOpen = state(false)
41
- const editorMode = state<'create' | 'edit'>('create')
42
- const editing = state<ShippingZoneRecord | null>(null)
43
- const saving = state(false)
44
- const editorError = state('')
45
- const deleting = state<ShippingZoneRecord | null>(null)
46
- const deleteBusy = state(false)
47
- const deleteError = state('')
48
75
 
49
76
  function listTokens(value: SerializedList): string[] {
50
77
  if (Array.isArray(value))
@@ -73,25 +100,6 @@ function withMethod(record: ShippingZoneRecord): ShippingZoneRecord {
73
100
  }
74
101
  }
75
102
 
76
- function filteredRecords(): ShippingZoneRecord[] {
77
- const query = search().trim().toLowerCase()
78
- if (!query)
79
- return records()
80
- return records().filter(record => [
81
- record.name,
82
- record.status,
83
- record.shipping_method?.name,
84
- ...listTokens(record.countries),
85
- ...listTokens(record.regions),
86
- ...listTokens(record.postal_codes),
87
- ].some(value => String(value || '').toLowerCase().includes(query)))
88
- }
89
-
90
- function paginatedRecords(): ShippingZoneRecord[] {
91
- const start = (page() - 1) * perPage()
92
- return filteredRecords().slice(start, start + perPage())
93
- }
94
-
95
103
  function uniqueCount(field: 'countries' | 'regions'): number {
96
104
  return new Set(records().flatMap(record => listTokens(record[field]))).size
97
105
  }
@@ -122,9 +130,7 @@ async function loadZones(): Promise<void> {
122
130
  throw new TypeError('Shipping zone resources were not returned as arrays.')
123
131
  methods.set(methodData)
124
132
  records.set(zoneData.map(withMethod))
125
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
126
- if (page() > lastPage)
127
- page.set(lastPage)
133
+ clampPage()
128
134
  }
129
135
  catch (error) {
130
136
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -135,33 +141,6 @@ async function loadZones(): Promise<void> {
135
141
  }
136
142
  }
137
143
 
138
- function handleSearch(value: string): void {
139
- search.set(value)
140
- page.set(1)
141
- }
142
-
143
- function openCreate(): void {
144
- editing.set(null)
145
- editorMode.set('create')
146
- editorError.set('')
147
- editorOpen.set(true)
148
- }
149
-
150
- function openEdit(record: ShippingZoneRecord): void {
151
- editing.set(record)
152
- editorMode.set('edit')
153
- editorError.set('')
154
- editorOpen.set(true)
155
- }
156
-
157
- function closeEditor(): void {
158
- if (saving())
159
- return
160
- editorOpen.set(false)
161
- editing.set(null)
162
- editorError.set('')
163
- }
164
-
165
144
  function setEditorError(message: string): void {
166
145
  editorError.set(message)
167
146
  }
@@ -181,14 +160,7 @@ async function saveZone(payload: ShippingZonePayload): Promise<void> {
181
160
  },
182
161
  )
183
162
  const hydrated = withMethod(updated)
184
- if (current) {
185
- records.set(records().map(record => record.id === current.id ? hydrated : record))
186
- }
187
- else {
188
- const nextRecords = [...records(), hydrated]
189
- records.set(nextRecords)
190
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
191
- }
163
+ upsert(hydrated, current)
192
164
  editorOpen.set(false)
193
165
  editing.set(null)
194
166
  pushToast('success', current ? 'Shipping zone updated' : 'Shipping zone added', {
@@ -203,18 +175,6 @@ async function saveZone(payload: ShippingZonePayload): Promise<void> {
203
175
  }
204
176
  }
205
177
 
206
- function requestDelete(record: ShippingZoneRecord): void {
207
- deleting.set(record)
208
- deleteError.set('')
209
- }
210
-
211
- function closeDelete(): void {
212
- if (deleteBusy())
213
- return
214
- deleting.set(null)
215
- deleteError.set('')
216
- }
217
-
218
178
  async function confirmDelete(): Promise<void> {
219
179
  const current = deleting()
220
180
  if (!current)
@@ -225,11 +185,9 @@ async function confirmDelete(): Promise<void> {
225
185
  await dashboardApi(`/api/dashboard/commerce/shipping-zones/${current.id}`, {
226
186
  method: 'DELETE',
227
187
  })
228
- records.set(records().filter(record => record.id !== current.id))
188
+ remove(current)
229
189
  deleting.set(null)
230
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
231
- if (page() > lastPage)
232
- page.set(lastPage)
190
+ clampPage()
233
191
  pushToast('success', 'Shipping zone deleted', { detail: current.name })
234
192
  }
235
193
  catch (error) {
@@ -240,10 +198,6 @@ async function confirmDelete(): Promise<void> {
240
198
  }
241
199
  }
242
200
 
243
- function goToPage(value: number): void {
244
- page.set(value)
245
- }
246
-
247
201
  onMount(() => {
248
202
  void loadZones()
249
203
  })
@@ -252,23 +206,17 @@ onMount(() => {
252
206
  <div class="space-y-6">
253
207
  <TabNavigation model-value="zones" />
254
208
 
255
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
256
- <div>
257
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Shipping zones</h1>
258
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Map methods to persisted country, region, and postal coverage.</p>
259
- </div>
260
- <div class="flex gap-2">
261
- <Button :loading="loading()" variant="secondary" @click="loadZones()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
262
- <Button :disabled="loading() || methods().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add zone</Button>
263
- </div>
264
- </header>
209
+ <PageHeader title="Shipping zones" description="Map methods to persisted country, region, and postal coverage.">
210
+ <div class="flex gap-2">
211
+ <Button :loading="loading()" variant="secondary" @click="loadZones()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
212
+ <Button :disabled="loading() || methods().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add zone</Button>
213
+ </div>
214
+ </PageHeader>
265
215
 
266
216
  <div :if="loadError()" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadZones()">Retry</Button></div>
267
217
  <div :if="!loading() && methods().length === 0" class="p-4 text-amber-800 text-sm dark:text-amber-200 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">Add a shipping method before creating a zone.</div>
268
218
 
269
- <section aria-label="Shipping zone metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
270
- <template :for="item in metricCards()" :key="item.label"><article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p></article></template>
271
- </section>
219
+ <MetricCards label="Shipping zone metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
272
220
 
273
221
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
274
222
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
2
3
  import type {
3
4
  PrintDeviceRecord,
4
5
  PrintDeviceStatus,
@@ -51,10 +52,7 @@ function submit(): void {
51
52
  }
52
53
 
53
54
  function errorMessages(): string[] {
54
- return Object.entries(errors()).map(([field, message]) => {
55
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
56
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
57
- })
55
+ return formErrorMessages(errors())
58
56
  }
59
57
  </script>
60
58
 
@@ -6,6 +6,7 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Commerce/print-device-records'
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
+ import { pageControls } from '../../../../functions/dashboard-list'
9
10
 
10
11
  interface PrintDevicesResponse {
11
12
  records: PrintDeviceRecord[]
@@ -234,13 +235,7 @@ async function deletePrintDevice(): Promise<void> {
234
235
  }
235
236
  }
236
237
 
237
- function previousPage(): void {
238
- page.set(Math.max(1, page() - 1))
239
- }
240
-
241
- function nextPage(): void {
242
- page.set(Math.min(pageCount(), page() + 1))
243
- }
238
+ const { previousPage, nextPage } = pageControls(page, pageCount)
244
239
 
245
240
  onMount(() => {
246
241
  void loadPrintDevices()
@@ -248,29 +243,17 @@ onMount(() => {
248
243
  </script>
249
244
 
250
245
  <div class="space-y-6">
251
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
252
- <div>
253
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Receipt printers</h1>
254
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native PrintDevice records and inspect their persisted operational state.</p>
255
- </div>
256
- <div class="flex flex-wrap gap-2">
257
- <Button variant="secondary" :loading="loading()" @click="loadPrintDevices"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
258
- <Button tag="a" variant="secondary" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
259
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add printer</Button>
260
- </div>
261
- </header>
246
+ <PageHeader title="Receipt printers" description="Manage native PrintDevice records and inspect their persisted operational state.">
247
+ <div class="flex flex-wrap gap-2">
248
+ <Button variant="secondary" :loading="loading()" @click="loadPrintDevices"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
249
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
250
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add printer</Button>
251
+ </div>
252
+ </PageHeader>
262
253
 
263
254
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
264
255
 
265
- <section aria-label="Print device metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
266
- <template :for="item in metricCards()" :key="item.label">
267
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
268
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
269
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
270
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
271
- </article>
272
- </template>
273
- </section>
256
+ <MetricCards label="Print device metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
274
257
 
275
258
  <PrintDeviceBreakdown :records="records()" />
276
259
 
@@ -189,28 +189,16 @@ onMount(() => {
189
189
  </script>
190
190
 
191
191
  <div class="space-y-6">
192
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
193
- <div>
194
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Print logs</h1>
195
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Inspect native Receipt records from configured print devices.</p>
196
- </div>
197
- <div class="flex flex-wrap gap-2">
198
- <Button variant="secondary" :loading="loading()" @click="loadPrintLogs"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
199
- <Button tag="a" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export current page</Button>
200
- </div>
201
- </header>
192
+ <PageHeader title="Print logs" description="Inspect native Receipt records from configured print devices.">
193
+ <div class="flex flex-wrap gap-2">
194
+ <Button variant="secondary" :loading="loading()" @click="loadPrintLogs"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
195
+ <Button tag="a" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export current page</Button>
196
+ </div>
197
+ </PageHeader>
202
198
 
203
199
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
204
200
 
205
- <section aria-label="Print log metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
206
- <template :for="item in metricCards()" :key="item.label">
207
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
208
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
209
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
210
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
211
- </article>
212
- </template>
213
- </section>
201
+ <MetricCards label="Print log metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
214
202
 
215
203
  <PrintLogBreakdown :records="records()" />
216
204
 
@@ -150,22 +150,18 @@ onMount(() => {
150
150
  </script>
151
151
 
152
152
  <div class="space-y-6">
153
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
154
- <div>
155
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Product Waitlist</h1>
156
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Native WaitlistProduct records without simulated customers or activity.</p>
157
- </div>
158
- <div class="flex gap-2">
159
- <Button variant="secondary" :loading="isLoading()" @click="loadRecords">
160
- <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
161
- Refresh
162
- </Button>
163
- <Button :disabled="isLoading() || options().products.length === 0" @click="openAddDialog()">
164
- <span class="h-4 w-4 i-hugeicons-add-01"></span>
165
- Add entry
166
- </Button>
167
- </div>
168
- </header>
153
+ <PageHeader title="Product Waitlist" description="Native WaitlistProduct records without simulated customers or activity.">
154
+ <div class="flex gap-2">
155
+ <Button variant="secondary" :loading="isLoading()" @click="loadRecords">
156
+ <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
157
+ Refresh
158
+ </Button>
159
+ <Button :disabled="isLoading() || options().products.length === 0" @click="openAddDialog()">
160
+ <span class="h-4 w-4 i-hugeicons-add-01"></span>
161
+ Add entry
162
+ </Button>
163
+ </div>
164
+ </PageHeader>
169
165
 
170
166
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
171
167
  <div :if="!isLoading() && options().products.length === 0" class="p-4 text-amber-800 text-sm dark:text-amber-200 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { ProductWaitlistOptions, ProductWaitlistRecord, ProductWaitlistWritePayload } from '../../../../app/Actions/Dashboard/Commerce/product-waitlist-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  type DialogMode = 'add' | 'edit'
5
6
 
@@ -94,10 +95,7 @@ function submitForm(): void {
94
95
  }
95
96
 
96
97
  function errorMessages(): string[] {
97
- return Object.entries(errors()).map(([field, message]) => {
98
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
99
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
100
- })
98
+ return formErrorMessages(errors())
101
99
  }
102
100
  </script>
103
101
 
@@ -207,22 +207,18 @@ onMount(() => {
207
207
  </script>
208
208
 
209
209
  <div class="space-y-6">
210
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
211
- <div>
212
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Restaurant Waitlist</h1>
213
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage persisted WaitlistRestaurant records and seating states.</p>
214
- </div>
215
- <div class="flex gap-2">
216
- <Button variant="secondary" :loading="isLoading()" @click="loadRecords">
217
- <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
218
- Refresh
219
- </Button>
220
- <Button :disabled="isLoading()" @click="openAddDialog()">
221
- <span class="h-4 w-4 i-hugeicons-add-01"></span>
222
- Add party
223
- </Button>
224
- </div>
225
- </header>
210
+ <PageHeader title="Restaurant Waitlist" description="Manage persisted WaitlistRestaurant records and seating states.">
211
+ <div class="flex gap-2">
212
+ <Button variant="secondary" :loading="isLoading()" @click="loadRecords">
213
+ <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
214
+ Refresh
215
+ </Button>
216
+ <Button :disabled="isLoading()" @click="openAddDialog()">
217
+ <span class="h-4 w-4 i-hugeicons-add-01"></span>
218
+ Add party
219
+ </Button>
220
+ </div>
221
+ </PageHeader>
226
222
 
227
223
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
228
224
 
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { RestaurantWaitlistOptions, RestaurantWaitlistRecord, RestaurantWaitlistWritePayload } from '../../../../app/Actions/Dashboard/Commerce/restaurant-waitlist-records'
3
3
  import { restaurantWaitlistDateTimeLocal, restaurantWaitlistTimestamp } from '../../../../app/Actions/Dashboard/Commerce/restaurant-waitlist-records'
4
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
4
5
 
5
6
  type DialogMode = 'add' | 'edit'
6
7
 
@@ -104,10 +105,7 @@ function submitForm(): void {
104
105
  }
105
106
 
106
107
  function errorMessages(): string[] {
107
- return Object.entries(errors()).map(([field, message]) => {
108
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
109
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
110
- })
108
+ return formErrorMessages(errors())
111
109
  }
112
110
  </script>
113
111
 
@@ -200,27 +200,23 @@ onMount(() => {
200
200
  </script>
201
201
 
202
202
  <div class="space-y-6">
203
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
204
- <div>
205
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Deployments</h1>
206
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Deployment model records through the guarded dashboard API.</p>
207
- </div>
208
- <div class="flex gap-2 items-center">
209
- <Button :disabled="isStarting() || isPreviewing()" variant="outline" @click="openDeploymentDialog('preview')">
210
- Preview
211
- </Button>
212
- <Button :disabled="isStarting() || isPreviewing() || isRollingBack()" variant="secondary" @click="openRollback">
213
- Rollback
214
- </Button>
215
- <Button :disabled="isStarting() || isPreviewing()" @click="openDeploymentDialog('deploy')">
216
- Deploy
217
- </Button>
218
- <Button :loading="isLoading()" variant="secondary" @click="loadDeployments">
219
- <span :if="!isLoading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
220
- Refresh
221
- </Button>
222
- </div>
223
- </header>
203
+ <PageHeader title="Deployments" description="Deployment model records through the guarded dashboard API.">
204
+ <div class="flex gap-2 items-center">
205
+ <Button :disabled="isStarting() || isPreviewing()" variant="outline" @click="openDeploymentDialog('preview')">
206
+ Preview
207
+ </Button>
208
+ <Button :disabled="isStarting() || isPreviewing() || isRollingBack()" variant="secondary" @click="openRollback">
209
+ Rollback
210
+ </Button>
211
+ <Button :disabled="isStarting() || isPreviewing()" @click="openDeploymentDialog('deploy')">
212
+ Deploy
213
+ </Button>
214
+ <Button :loading="isLoading()" variant="secondary" @click="loadDeployments">
215
+ <span :if="!isLoading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
216
+ Refresh
217
+ </Button>
218
+ </div>
219
+ </PageHeader>
224
220
 
225
221
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
226
222
 
@@ -8,6 +8,7 @@ import type {
8
8
  import { formatCurrency } from '@stacksjs/commerce/money'
9
9
  import { dashboardApi } from '../../../../functions/dashboard-api'
10
10
  import { pushToast } from '../../../../functions/toasts'
11
+ import { pageControls } from '../../../../functions/dashboard-list'
11
12
 
12
13
  const emptySummary: AbandonedCartSummary = {
13
14
  open: 0,
@@ -162,15 +163,7 @@ async function createCampaign(payload: Record<string, unknown>): Promise<void> {
162
163
  }
163
164
  }
164
165
 
165
- function previousPage(): void {
166
- if (page() > 1)
167
- page.set(page() - 1)
168
- }
169
-
170
- function nextPage(): void {
171
- if (page() < totalPages())
172
- page.set(page() + 1)
173
- }
166
+ const { previousPage, nextPage } = pageControls(page, totalPages)
174
167
 
175
168
  onMount(() => {
176
169
  void loadCarts()
@@ -178,16 +171,12 @@ onMount(() => {
178
171
  </script>
179
172
 
180
173
  <div class="space-y-6">
181
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
182
- <div>
183
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Abandoned Carts</h1>
184
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">The audience that already chose the products. Every other campaign starts by guessing.</p>
185
- </div>
186
- <div class="flex gap-2">
187
- <Button :loading="loading()" variant="secondary" @click="loadCarts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
188
- <Button @click="openCompose()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-mail-send-01"></span>New recovery campaign</Button>
189
- </div>
190
- </header>
174
+ <PageHeader title="Abandoned Carts" description="The audience that already chose the products. Every other campaign starts by guessing.">
175
+ <div class="flex gap-2">
176
+ <Button :loading="loading()" variant="secondary" @click="loadCarts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
177
+ <Button @click="openCompose()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-mail-send-01"></span>New recovery campaign</Button>
178
+ </div>
179
+ </PageHeader>
191
180
 
192
181
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadCarts()">Retry</Button></div>
193
182
 
@@ -7,6 +7,7 @@ import type {
7
7
  import type { CampaignDeliveryOperation } from '../../../../app/Actions/Dashboard/Marketing/campaign-delivery'
8
8
  import { dashboardApi } from '../../../../functions/dashboard-api'
9
9
  import { pushToast } from '../../../../functions/toasts'
10
+ import { pageControls } from '../../../../functions/dashboard-list'
10
11
 
11
12
  interface CampaignIndexResponse {
12
13
  records: CampaignRecord[]
@@ -271,15 +272,7 @@ async function confirmDelivery(payload: { scheduledAt?: string }): Promise<void>
271
272
  }
272
273
  }
273
274
 
274
- function previousPage(): void {
275
- if (page() > 1)
276
- page.set(page() - 1)
277
- }
278
-
279
- function nextPage(): void {
280
- if (page() < totalPages())
281
- page.set(page() + 1)
282
- }
275
+ const { previousPage, nextPage } = pageControls(page, totalPages)
283
276
 
284
277
  onMount(() => {
285
278
  void loadCampaigns()
@@ -287,16 +280,12 @@ onMount(() => {
287
280
  </script>
288
281
 
289
282
  <div class="space-y-6">
290
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
291
- <div>
292
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Campaigns</h1>
293
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage Campaign records with real audience and delivery aggregates.</p>
294
- </div>
295
- <div class="flex gap-2">
296
- <Button :loading="loading()" variant="secondary" @click="loadCampaigns()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
297
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add campaign</Button>
298
- </div>
299
- </header>
283
+ <PageHeader title="Campaigns" description="Manage Campaign records with real audience and delivery aggregates.">
284
+ <div class="flex gap-2">
285
+ <Button :loading="loading()" variant="secondary" @click="loadCampaigns()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
286
+ <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add campaign</Button>
287
+ </div>
288
+ </PageHeader>
300
289
 
301
290
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadCampaigns()">Retry</Button></div>
302
291