@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
@@ -175,33 +175,22 @@ onMount(() => {
175
175
  </script>
176
176
 
177
177
  <div class="space-y-6">
178
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
179
- <div>
180
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Manufacturers</h1>
181
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native Manufacturer records and their product relationships.</p>
182
- </div>
183
- <div class="flex gap-2">
184
- <Button variant="secondary" :loading="loading()" @click="loadManufacturers">
185
- <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
186
- Refresh
187
- </Button>
188
- <Button @click="openAdd()">
189
- <span class="h-4 w-4 i-hugeicons-add-01"></span>
190
- Add manufacturer
191
- </Button>
192
- </div>
193
- </header>
178
+ <PageHeader title="Manufacturers" description="Manage native Manufacturer records and their product relationships.">
179
+ <div class="flex gap-2">
180
+ <Button variant="secondary" :loading="loading()" @click="loadManufacturers">
181
+ <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
182
+ Refresh
183
+ </Button>
184
+ <Button @click="openAdd()">
185
+ <span class="h-4 w-4 i-hugeicons-add-01"></span>
186
+ Add manufacturer
187
+ </Button>
188
+ </div>
189
+ </PageHeader>
194
190
 
195
191
  <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>
196
192
 
197
- <section aria-label="Manufacturer metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
198
- <template :for="item in metricCards()" :key="item.label">
199
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
200
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
201
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
202
- </article>
203
- </template>
204
- </section>
193
+ <MetricCards label="Manufacturer metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
205
194
 
206
195
  <section class="space-y-3">
207
196
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
@@ -4,6 +4,7 @@ import type {
4
4
  OrderCustomerOption,
5
5
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-order-records'
6
6
  import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
7
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
7
8
 
8
9
  const open = useReactiveProp('open', false)
9
10
  const mode = useReactiveProp('mode', 'add')
@@ -108,10 +109,7 @@ function submit(): void {
108
109
  }
109
110
 
110
111
  function errorMessages(): string[] {
111
- return Object.entries(errors()).map(([field, message]) => {
112
- const fieldLabel = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
113
- return `${fieldLabel.charAt(0).toUpperCase()}${fieldLabel.slice(1)}: ${message}`
114
- })
112
+ return formErrorMessages(errors())
115
113
  }
116
114
  </script>
117
115
 
@@ -7,6 +7,7 @@ import type {
7
7
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
9
  import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
10
+ import { pageControls } from '../../../../functions/dashboard-list'
10
11
 
11
12
  interface OrderIndexResponse {
12
13
  records: CommerceOrderRecord[]
@@ -284,13 +285,7 @@ async function deleteOrder(): Promise<void> {
284
285
  }
285
286
  }
286
287
 
287
- function previousPage(): void {
288
- page.set(Math.max(1, page() - 1))
289
- }
290
-
291
- function nextPage(): void {
292
- page.set(Math.min(pageCount(), page() + 1))
293
- }
288
+ const { previousPage, nextPage } = pageControls(page, pageCount)
294
289
 
295
290
  onMount(() => {
296
291
  void loadOrders()
@@ -298,29 +293,17 @@ onMount(() => {
298
293
  </script>
299
294
 
300
295
  <div class="space-y-6">
301
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
302
- <div>
303
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Orders</h1>
304
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native Order records, customer assignments, totals, and fulfillment state.</p>
305
- </div>
306
- <div class="flex flex-wrap gap-2">
307
- <Button variant="secondary" :loading="loading()" @click="loadOrders()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
308
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'orders-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
309
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add order</Button>
310
- </div>
311
- </header>
296
+ <PageHeader title="Orders" description="Manage native Order records, customer assignments, totals, and fulfillment state.">
297
+ <div class="flex flex-wrap gap-2">
298
+ <Button variant="secondary" :loading="loading()" @click="loadOrders()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
299
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'orders-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
300
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add order</Button>
301
+ </div>
302
+ </PageHeader>
312
303
 
313
304
  <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>
314
305
 
315
- <section aria-label="Order metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-5">
316
- <template :for="item in metricCards()" :key="item.label">
317
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
318
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
319
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
320
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
321
- </article>
322
- </template>
323
- </section>
306
+ <MetricCards label="Order metrics" columns="grid-cols-2 lg:grid-cols-5" :items="metricCards()" />
324
307
 
325
308
  <section :if="summary().currencyTotals.length > 0" aria-label="Order currency totals" class="flex flex-wrap gap-2 items-center">
326
309
  <span class="text-gray-500 text-xs dark:text-neutral-400">Recorded order value</span>
@@ -62,27 +62,23 @@ onMount(() => {
62
62
  </script>
63
63
 
64
64
  <div class="space-y-8">
65
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
66
- <div>
67
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Commerce Dashboard</h1>
68
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Persisted order performance for {{ rangeLabel().toLowerCase() }}.</p>
69
- </div>
70
- <div class="flex gap-2">
71
- <Button variant="secondary" :loading="loading()" @click="loadDashboard">
72
- <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
73
- Refresh
74
- </Button>
75
- <label class="sr-only" for="commerce-dashboard-range">Reporting range</label>
76
- <select id="commerce-dashboard-range" x-model="range" class="block pl-3 pr-10 py-2 text-gray-900 sm:leading-6 sm:text-sm dark:text-white dark:bg-neutral-800 border-0 ring-1 ring-gray-300 ring-inset rounded-md dark:ring-neutral-700 focus:ring-2 focus:ring-blue-600" @change="reloadSelectedRange($event)">
77
- <option value="today">Today</option>
78
- <option value="7d">Last 7 days</option>
79
- <option value="30d">Last 30 days</option>
80
- <option value="90d">Last 90 days</option>
81
- <option value="year">Last year</option>
82
- <option value="all">All time</option>
83
- </select>
84
- </div>
85
- </header>
65
+ <PageHeader title="Commerce Dashboard" :description="`Persisted order performance for ${rangeLabel().toLowerCase()}.`">
66
+ <div class="flex gap-2">
67
+ <Button variant="secondary" :loading="loading()" @click="loadDashboard">
68
+ <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
69
+ Refresh
70
+ </Button>
71
+ <label class="sr-only" for="commerce-dashboard-range">Reporting range</label>
72
+ <select id="commerce-dashboard-range" x-model="range" class="block pl-3 pr-10 py-2 text-gray-900 sm:leading-6 sm:text-sm dark:text-white dark:bg-neutral-800 border-0 ring-1 ring-gray-300 ring-inset rounded-md dark:ring-neutral-700 focus:ring-2 focus:ring-blue-600" @change="reloadSelectedRange($event)">
73
+ <option value="today">Today</option>
74
+ <option value="7d">Last 7 days</option>
75
+ <option value="30d">Last 30 days</option>
76
+ <option value="90d">Last 90 days</option>
77
+ <option value="year">Last year</option>
78
+ <option value="all">All time</option>
79
+ </select>
80
+ </div>
81
+ </PageHeader>
86
82
 
87
83
  <div :if="loadError()" class="px-4 py-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-300 rounded-md dark:border-red-800">
88
84
  {{ loadError() }}
@@ -170,28 +170,16 @@ onMount(() => {
170
170
  </script>
171
171
 
172
172
  <div class="space-y-6">
173
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
174
- <div>
175
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Payments</h1>
176
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Inspect native Payment records, export the current view, and record confirmed refunds.</p>
177
- </div>
178
- <div class="flex flex-wrap gap-2">
179
- <Button variant="secondary" :loading="loading()" @click="loadPayments"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
180
- <Button tag="a" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export current view</Button>
181
- </div>
182
- </header>
173
+ <PageHeader title="Payments" description="Inspect native Payment records, export the current view, and record confirmed refunds.">
174
+ <div class="flex flex-wrap gap-2">
175
+ <Button variant="secondary" :loading="loading()" @click="loadPayments"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
176
+ <Button tag="a" :href="exportHref()" :download="exportFilename()"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export current view</Button>
177
+ </div>
178
+ </PageHeader>
183
179
 
184
180
  <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>
185
181
 
186
- <section aria-label="Payment metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
187
- <template :for="item in metricCards()" :key="item.label">
188
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
189
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
190
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
191
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
192
- </article>
193
- </template>
194
- </section>
182
+ <MetricCards label="Payment metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
195
183
 
196
184
  <CommercePaymentsBreakdown :records="visibleRecords()" />
197
185
 
@@ -314,25 +314,13 @@ onMount(() => {
314
314
  </script>
315
315
 
316
316
  <div class="space-y-6">
317
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
318
- <div>
319
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Point of sale</h1>
320
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create server-priced cash sales from native Product, Order, OrderItem, Payment, Customer, and TaxRate records.</p>
321
- </div>
322
- <Button variant="secondary" :loading="loading()" @click="loadPos()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
323
- </header>
317
+ <PageHeader title="Point of sale" description="Create server-priced cash sales from native Product, Order, OrderItem, Payment, Customer, and TaxRate records.">
318
+ <Button variant="secondary" :loading="loading()" @click="loadPos()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
319
+ </PageHeader>
324
320
 
325
321
  <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>
326
322
 
327
- <section aria-label="Point of sale metrics" class="grid gap-3 grid-cols-2 xl:grid-cols-4">
328
- <template :for="item in metricCards()" :key="item.label">
329
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
330
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
331
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
332
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
333
- </article>
334
- </template>
335
- </section>
323
+ <MetricCards label="Point of sale metrics" columns="grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
336
324
 
337
325
  <div class="grid gap-6 grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_20rem]">
338
326
  <CommercePosCatalog :products="visibleProducts()" :categories="categories()" :search="search()" :category="categoryFilter()" :availability="availabilityFilter()" :currency="defaultCurrency()" :loading="loading()" @search="search.set($event)" @category="categoryFilter.set($event)" @availability="availabilityFilter.set($event)" @add="addProduct" />
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
2
3
  import type {
3
4
  CommerceProductRecord,
4
5
  ProductOption,
@@ -108,10 +109,7 @@ function submit(): void {
108
109
  }
109
110
 
110
111
  function errorMessages(): string[] {
111
- return Object.entries(errors()).map(([field, message]) => {
112
- const fieldLabel = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
113
- return `${fieldLabel.charAt(0).toUpperCase()}${fieldLabel.slice(1)}: ${message}`
114
- })
112
+ return formErrorMessages(errors())
115
113
  }
116
114
  </script>
117
115
 
@@ -8,6 +8,7 @@ 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'
11
12
 
12
13
  interface ProductIndexResponse {
13
14
  records: CommerceProductRecord[]
@@ -259,13 +260,7 @@ async function deleteProduct(): Promise<void> {
259
260
  }
260
261
  }
261
262
 
262
- function previousPage(): void {
263
- page.set(Math.max(1, page() - 1))
264
- }
265
-
266
- function nextPage(): void {
267
- page.set(Math.min(pageCount(), page() + 1))
268
- }
263
+ const { previousPage, nextPage } = pageControls(page, pageCount)
269
264
 
270
265
  onMount(() => {
271
266
  void loadProducts()
@@ -273,29 +268,17 @@ onMount(() => {
273
268
  </script>
274
269
 
275
270
  <div class="space-y-6">
276
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
277
- <div>
278
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Products</h1>
279
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native Product records, commerce availability, inventory, and relationships.</p>
280
- </div>
281
- <div class="flex flex-wrap gap-2">
282
- <Button variant="secondary" :loading="loading()" @click="loadProducts()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
283
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'products-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
284
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add product</Button>
285
- </div>
286
- </header>
271
+ <PageHeader title="Products" description="Manage native Product records, commerce availability, inventory, and relationships.">
272
+ <div class="flex flex-wrap gap-2">
273
+ <Button variant="secondary" :loading="loading()" @click="loadProducts()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
274
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'products-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
275
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add product</Button>
276
+ </div>
277
+ </PageHeader>
287
278
 
288
279
  <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>
289
280
 
290
- <section aria-label="Product metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-5">
291
- <template :for="item in metricCards()" :key="item.label">
292
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
293
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
294
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
295
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
296
- </article>
297
- </template>
298
- </section>
281
+ <MetricCards label="Product metrics" columns="grid-cols-2 lg:grid-cols-5" :items="metricCards()" />
299
282
 
300
283
  <section class="space-y-3">
301
284
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { ReviewRecord } from '../../../../app/Actions/Dashboard/Commerce/review-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const review = useReactiveProp('review', null as ReviewRecord | null)
@@ -42,10 +43,7 @@ function submit(): void {
42
43
  }
43
44
 
44
45
  function errorMessages(): string[] {
45
- return Object.entries(errors()).map(([field, message]) => {
46
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
47
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
48
- })
46
+ return formErrorMessages(errors())
49
47
  }
50
48
  </script>
51
49
 
@@ -281,27 +281,16 @@ onMount(() => {
281
281
  </script>
282
282
 
283
283
  <div class="space-y-6">
284
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
285
- <div>
286
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">{{ title() }}</h1>
287
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">{{ description() }}</p>
288
- </div>
289
- <Button variant="secondary" :loading="loading()" @click="loadReviews">
290
- <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
291
- Refresh
292
- </Button>
293
- </header>
284
+ <PageHeader :title="title()" :description="description()">
285
+ <Button variant="secondary" :loading="loading()" @click="loadReviews">
286
+ <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
287
+ Refresh
288
+ </Button>
289
+ </PageHeader>
294
290
 
295
291
  <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>
296
292
 
297
- <section aria-label="Review metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-5">
298
- <template :for="item in metricCards()" :key="item.label">
299
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
300
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
301
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
302
- </article>
303
- </template>
304
- </section>
293
+ <MetricCards label="Review metrics" columns="grid-cols-2 lg:grid-cols-5" :items="metricCards()" />
305
294
 
306
295
  <section class="space-y-3">
307
296
  <div class="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-5">
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { TaxRateRecord } from '../../../../app/Actions/Dashboard/Commerce/tax-rate-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -59,10 +60,7 @@ function submit(): void {
59
60
  }
60
61
 
61
62
  function errorMessages(): string[] {
62
- return Object.entries(errors()).map(([field, message]) => {
63
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
64
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
65
- })
63
+ return formErrorMessages(errors())
66
64
  }
67
65
  </script>
68
66
 
@@ -203,22 +203,18 @@ onMount(() => {
203
203
  </script>
204
204
 
205
205
  <div class="space-y-6">
206
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
207
- <div>
208
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Tax rates</h1>
209
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native TaxRate records used by commerce calculations.</p>
210
- </div>
211
- <div class="flex gap-2">
212
- <Button variant="secondary" :loading="loading()" @click="loadTaxes">
213
- <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
214
- Refresh
215
- </Button>
216
- <Button @click="openAdd()">
217
- <span class="h-4 w-4 i-hugeicons-add-01"></span>
218
- Add tax rate
219
- </Button>
220
- </div>
221
- </header>
206
+ <PageHeader title="Tax rates" description="Manage native TaxRate records used by commerce calculations.">
207
+ <div class="flex gap-2">
208
+ <Button variant="secondary" :loading="loading()" @click="loadTaxes">
209
+ <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
210
+ Refresh
211
+ </Button>
212
+ <Button @click="openAdd()">
213
+ <span class="h-4 w-4 i-hugeicons-add-01"></span>
214
+ Add tax rate
215
+ </Button>
216
+ </div>
217
+ </PageHeader>
222
218
 
223
219
  <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>
224
220
  <div :if="summary().defaultConflicts > 0" class="flex gap-3 items-start 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">
@@ -226,14 +222,7 @@ onMount(() => {
226
222
  <p>{{ summary().defaultConflicts + 1 }} tax rates are marked as default. Save the intended default to clear the others.</p>
227
223
  </div>
228
224
 
229
- <section aria-label="Tax rate metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
230
- <template :for="item in metricCards()" :key="item.label">
231
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
232
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
233
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
234
- </article>
235
- </template>
236
- </section>
225
+ <MetricCards label="Tax rate metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
237
226
 
238
227
  <section class="space-y-3">
239
228
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-5">
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
2
3
  import type {
3
4
  ProductUnitOption,
4
5
  ProductUnitRecord,
@@ -52,10 +53,7 @@ function submit(): void {
52
53
  }
53
54
 
54
55
  function errorMessages(): string[] {
55
- return Object.entries(errors()).map(([field, message]) => {
56
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
57
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
58
- })
56
+ return formErrorMessages(errors())
59
57
  }
60
58
  </script>
61
59
 
@@ -203,22 +203,18 @@ onMount(() => {
203
203
  </script>
204
204
 
205
205
  <div class="space-y-6">
206
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
207
- <div>
208
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Units</h1>
209
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native ProductUnit records and their product relationships.</p>
210
- </div>
211
- <div class="flex gap-2">
212
- <Button variant="secondary" :loading="loading()" @click="loadUnits">
213
- <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
214
- Refresh
215
- </Button>
216
- <Button @click="openAdd()">
217
- <span class="h-4 w-4 i-hugeicons-add-01"></span>
218
- Add unit
219
- </Button>
220
- </div>
221
- </header>
206
+ <PageHeader title="Units" description="Manage native ProductUnit records and their product relationships.">
207
+ <div class="flex gap-2">
208
+ <Button variant="secondary" :loading="loading()" @click="loadUnits">
209
+ <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
210
+ Refresh
211
+ </Button>
212
+ <Button @click="openAdd()">
213
+ <span class="h-4 w-4 i-hugeicons-add-01"></span>
214
+ Add unit
215
+ </Button>
216
+ </div>
217
+ </PageHeader>
222
218
 
223
219
  <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>
224
220
  <div :if="summary().defaultConflicts > 0" class="flex gap-3 items-start 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">
@@ -226,14 +222,7 @@ onMount(() => {
226
222
  <p>{{ summary().defaultConflicts }} unit {{ summary().defaultConflicts === 1 ? 'type has' : 'types have' }} competing defaults. Save the intended default to normalize that type.</p>
227
223
  </div>
228
224
 
229
- <section aria-label="Product unit metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
230
- <template :for="item in metricCards()" :key="item.label">
231
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
232
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
233
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
234
- </article>
235
- </template>
236
- </section>
225
+ <MetricCards label="Product unit metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
237
226
 
238
227
  <section class="space-y-3">
239
228
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { ProductVariantOption, ProductVariantRecord } from '../../../../app/Actions/Dashboard/Commerce/product-variant-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -49,10 +50,7 @@ function submit(): void {
49
50
  }
50
51
 
51
52
  function errorMessages(): string[] {
52
- return Object.entries(errors()).map(([field, message]) => {
53
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
54
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
55
- })
53
+ return formErrorMessages(errors())
56
54
  }
57
55
  </script>
58
56
 
@@ -198,27 +198,16 @@ onMount(() => {
198
198
  </script>
199
199
 
200
200
  <div class="space-y-6">
201
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
202
- <div>
203
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Product variants</h1>
204
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native ProductVariant records, option sets, and product relationships.</p>
205
- </div>
206
- <div class="flex gap-2">
207
- <Button variant="secondary" :loading="loading()" @click="loadVariants"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
208
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add variant</Button>
209
- </div>
210
- </header>
201
+ <PageHeader title="Product variants" description="Manage native ProductVariant records, option sets, and product relationships.">
202
+ <div class="flex gap-2">
203
+ <Button variant="secondary" :loading="loading()" @click="loadVariants"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
204
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add variant</Button>
205
+ </div>
206
+ </PageHeader>
211
207
 
212
208
  <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>
213
209
 
214
- <section aria-label="Product variant metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
215
- <template :for="item in metricCards()" :key="item.label">
216
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
217
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
218
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
219
- </article>
220
- </template>
221
- </section>
210
+ <MetricCards label="Product variant metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
222
211
 
223
212
  <section class="space-y-3">
224
213
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-5">