@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
@@ -43,121 +43,107 @@ function handleSubmit(): void {
43
43
  }
44
44
  </script>
45
45
 
46
- <div class="flex items-center justify-center px-4 py-12 min-h-screen">
47
- <div class="max-w-sm w-full">
48
- <!-- Logo/Brand -->
49
- <div class="mb-8 text-center">
50
- <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-blue-500/25 shadow-lg">
51
- <span class="h-7 w-7 text-white i-hugeicons-cube"></span>
46
+ <AuthCard :title="title()" :subtitle="subtitle()">
47
+
48
+ <!-- Form Card -->
49
+ <div class="p-6 bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800 shadow-black/5 shadow-xl dark:shadow-black/20">
50
+ <div :if="checking()">
51
+ <div class="py-4 text-center">
52
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-neutral-100 dark:bg-neutral-800 rounded-full">
53
+ <div class="h-6 w-6 text-neutral-500 animate-spin i-hugeicons-loading-03"></div>
54
+ </div>
55
+ <p class="text-neutral-500 text-sm dark:text-neutral-400">Checking your link</p>
52
56
  </div>
53
- <h1 class="font-semibold text-2xl text-neutral-900 dark:text-white">
54
- {{ title() }}
55
- </h1>
56
- <p class="mt-2 text-neutral-500 text-sm dark:text-neutral-400">
57
- {{ subtitle() }}
58
- </p>
59
57
  </div>
60
58
 
61
- <!-- Form Card -->
62
- <div class="p-6 bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800 shadow-black/5 shadow-xl dark:shadow-black/20">
63
- <div :if="checking()">
64
- <div class="py-4 text-center">
65
- <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-neutral-100 dark:bg-neutral-800 rounded-full">
66
- <div class="h-6 w-6 text-neutral-500 animate-spin i-hugeicons-loading-03"></div>
67
- </div>
68
- <p class="text-neutral-500 text-sm dark:text-neutral-400">Checking your link</p>
59
+ <div :else-if="invalid()">
60
+ <div class="py-4 text-center">
61
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-red-100 dark:bg-red-900/30 rounded-full">
62
+ <div class="h-6 w-6 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
69
63
  </div>
64
+ <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
65
+ This link no longer works
66
+ </h2>
67
+ <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
68
+ Reset links expire, and each one can be used only once. Ask for a fresh link and it will arrive in a moment.
69
+ </p>
70
+ <Button href="/forgot-password">
71
+ Send a new link
72
+ </Button>
70
73
  </div>
74
+ </div>
71
75
 
72
- <div :else-if="invalid()">
73
- <div class="py-4 text-center">
74
- <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-red-100 dark:bg-red-900/30 rounded-full">
75
- <div class="h-6 w-6 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
76
- </div>
77
- <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
78
- This link no longer works
79
- </h2>
80
- <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
81
- Reset links expire, and each one can be used only once. Ask for a fresh link and it will arrive in a moment.
82
- </p>
83
- <Button href="/forgot-password">
84
- Send a new link
85
- </Button>
76
+ <div :else-if="success()">
77
+ <div class="py-4 text-center">
78
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-green-100 dark:bg-green-900/30 rounded-full">
79
+ <div class="h-6 w-6 text-green-600 dark:text-green-400 i-hugeicons-checkmark-circle-02"></div>
86
80
  </div>
81
+ <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
82
+ Password changed
83
+ </h2>
84
+ <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
85
+ You can sign in with your new password now.
86
+ </p>
87
+ <Button href="/login">
88
+ Go to sign in
89
+ </Button>
87
90
  </div>
91
+ </div>
88
92
 
89
- <div :else-if="success()">
90
- <div class="py-4 text-center">
91
- <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-green-100 dark:bg-green-900/30 rounded-full">
92
- <div class="h-6 w-6 text-green-600 dark:text-green-400 i-hugeicons-checkmark-circle-02"></div>
93
- </div>
94
- <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
95
- Password changed
96
- </h2>
97
- <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
98
- You can sign in with your new password now.
99
- </p>
100
- <Button href="/login">
101
- Go to sign in
102
- </Button>
93
+ <div :else>
94
+ <div :if="error()" role="alert" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-lg dark:border-red-800">
95
+ <div class="flex gap-2 items-center">
96
+ <div class="h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
97
+ <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
103
98
  </div>
104
99
  </div>
105
100
 
106
- <div :else>
107
- <div :if="error()" role="alert" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-lg dark:border-red-800">
108
- <div class="flex gap-2 items-center">
109
- <div class="h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
110
- <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
111
- </div>
101
+ <form @submit.prevent="handleSubmit">
102
+ <div class="mb-4">
103
+ <Input
104
+ type="password"
105
+ label="New password"
106
+ placeholder="At least 8 characters"
107
+ autocomplete="new-password"
108
+ v-model:value="password"
109
+ required
110
+ :disabled="isLoading()"
111
+ />
112
112
  </div>
113
113
 
114
- <form @submit.prevent="handleSubmit">
115
- <div class="mb-4">
116
- <Input
117
- type="password"
118
- label="New password"
119
- placeholder="At least 8 characters"
120
- autocomplete="new-password"
121
- v-model:value="password"
122
- required
123
- :disabled="isLoading()"
124
- />
125
- </div>
126
-
127
- <div class="mb-6">
128
- <Input
129
- type="password"
130
- label="Confirm new password"
131
- placeholder="Repeat your new password"
132
- autocomplete="new-password"
133
- v-model:value="confirmation"
134
- :error="mismatch() ? 'Both passwords must match' : ''"
135
- required
136
- :disabled="isLoading()"
137
- />
138
- </div>
139
-
140
- <Button
141
- type="submit"
142
- variant="primary"
143
- size="lg"
144
- fullWidth
145
- :loading="isLoading()"
146
- :disabled="!canSubmit()"
147
- >
148
- <span :if="isLoading()">Saving...</span>
149
- <span :if="!isLoading()">Change password</span>
150
- </Button>
151
- </form>
152
- </div>
153
- </div>
114
+ <div class="mb-6">
115
+ <Input
116
+ type="password"
117
+ label="Confirm new password"
118
+ placeholder="Repeat your new password"
119
+ autocomplete="new-password"
120
+ v-model:value="confirmation"
121
+ :error="mismatch() ? 'Both passwords must match' : ''"
122
+ required
123
+ :disabled="isLoading()"
124
+ />
125
+ </div>
154
126
 
155
- <!-- Back to Login -->
156
- <p class="mt-6 text-center text-neutral-500 text-sm dark:text-neutral-400">
157
- <a href="/login" class="inline-flex gap-1 items-center font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
158
- <div class="h-4 w-4 i-hugeicons-arrow-left-01"></div>
159
- Back to sign in
160
- </a>
161
- </p>
127
+ <Button
128
+ type="submit"
129
+ variant="primary"
130
+ size="lg"
131
+ fullWidth
132
+ :loading="isLoading()"
133
+ :disabled="!canSubmit()"
134
+ >
135
+ <span :if="isLoading()">Saving...</span>
136
+ <span :if="!isLoading()">Change password</span>
137
+ </Button>
138
+ </form>
139
+ </div>
162
140
  </div>
163
- </div>
141
+
142
+ <!-- Back to Login -->
143
+ <p class="mt-6 text-center text-neutral-500 text-sm dark:text-neutral-400">
144
+ <a href="/login" class="inline-flex gap-1 items-center font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
145
+ <div class="h-4 w-4 i-hugeicons-arrow-left-01"></div>
146
+ Back to sign in
147
+ </a>
148
+ </p>
149
+ </AuthCard>
@@ -84,21 +84,17 @@ onMount(() => {
84
84
  </script>
85
85
 
86
86
  <div class="space-y-6">
87
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
88
- <div>
89
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Buddy</h1>
90
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Ask framework questions through your configured Stacks AI provider.</p>
91
- </div>
92
- <div class="flex gap-2 items-center">
93
- <span :class="'inline-flex items-center px-2.5 py-1 font-medium text-xs rounded-full ' + (chatState()?.configured ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950' : 'text-amber-700 bg-amber-100 dark:text-amber-300 dark:bg-amber-950')">
94
- <span :class="'mr-1.5 h-1.5 w-1.5 rounded-full ' + (chatState()?.configured ? 'bg-green-500' : 'bg-amber-500')"></span>
95
- {{ providerName() }} {{ chatState()?.configured ? 'ready' : 'needs configuration' }}
96
- </span>
97
- <Button :if="messages().length > 0" variant="secondary" size="sm" @click="clearHistory">
98
- Clear
99
- </Button>
100
- </div>
101
- </header>
87
+ <PageHeader title="Buddy" description="Ask framework questions through your configured Stacks AI provider.">
88
+ <div class="flex gap-2 items-center">
89
+ <span :class="'inline-flex items-center px-2.5 py-1 font-medium text-xs rounded-full ' + (chatState()?.configured ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950' : 'text-amber-700 bg-amber-100 dark:text-amber-300 dark:bg-amber-950')">
90
+ <span :class="'mr-1.5 h-1.5 w-1.5 rounded-full ' + (chatState()?.configured ? 'bg-green-500' : 'bg-amber-500')"></span>
91
+ {{ providerName() }} {{ chatState()?.configured ? 'ready' : 'needs configuration' }}
92
+ </span>
93
+ <Button :if="messages().length > 0" variant="secondary" size="sm" @click="clearHistory">
94
+ Clear
95
+ </Button>
96
+ </div>
97
+ </PageHeader>
102
98
 
103
99
  <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">
104
100
  {{ loadError() }}
@@ -5,6 +5,7 @@ import type {
5
5
  } from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
6
6
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
7
7
  import { pushToast } from '../../../../functions/toasts'
8
+ import { pageControls } from '../../../../functions/dashboard-list'
8
9
 
9
10
  interface CategoryIndexResponse {
10
11
  records: CommerceCategoryRecord[]
@@ -230,13 +231,7 @@ async function deleteCategory(): Promise<void> {
230
231
  }
231
232
  }
232
233
 
233
- function previousPage(): void {
234
- page.set(Math.max(1, page() - 1))
235
- }
236
-
237
- function nextPage(): void {
238
- page.set(Math.min(pageCount(), page() + 1))
239
- }
234
+ const { previousPage, nextPage } = pageControls(page, pageCount)
240
235
 
241
236
  onMount(() => {
242
237
  void loadCategories()
@@ -244,29 +239,17 @@ onMount(() => {
244
239
  </script>
245
240
 
246
241
  <div class="space-y-6">
247
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
248
- <div>
249
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Product Categories</h1>
250
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage the native Category hierarchy, display order, and storefront availability.</p>
251
- </div>
252
- <div class="flex flex-wrap gap-2">
253
- <Button variant="secondary" :loading="loading()" @click="loadCategories()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
254
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'product-categories-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
255
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add category</Button>
256
- </div>
257
- </header>
242
+ <PageHeader title="Product Categories" description="Manage the native Category hierarchy, display order, and storefront availability.">
243
+ <div class="flex flex-wrap gap-2">
244
+ <Button variant="secondary" :loading="loading()" @click="loadCategories()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
245
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'product-categories-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
246
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add category</Button>
247
+ </div>
248
+ </PageHeader>
258
249
 
259
250
  <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>
260
251
 
261
- <section aria-label="Product category metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
262
- <template :for="item in metricCards()" :key="item.label">
263
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
264
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
265
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
266
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
267
- </article>
268
- </template>
269
- </section>
252
+ <MetricCards label="Product category metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
270
253
 
271
254
  <CommerceCategoryHierarchy :records="records()" />
272
255
 
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { CommerceCategoryRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -113,10 +114,7 @@ function submit(): void {
113
114
  }
114
115
 
115
116
  function errorMessages(): string[] {
116
- return Object.entries(errors()).map(([field, message]) => {
117
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
118
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
119
- })
117
+ return formErrorMessages(errors())
120
118
  }
121
119
  </script>
122
120
 
@@ -5,6 +5,7 @@ import type {
5
5
  CouponStatus,
6
6
  } from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
7
7
  import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
8
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
8
9
 
9
10
  const open = useReactiveProp('open', false)
10
11
  const mode = useReactiveProp('mode', 'add')
@@ -104,10 +105,7 @@ function submit(): 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
 
@@ -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 CouponIndexResponse {
13
14
  records: CouponRecord[]
@@ -241,13 +242,7 @@ async function deleteCoupon(): Promise<void> {
241
242
  }
242
243
  }
243
244
 
244
- function previousPage(): void {
245
- page.set(Math.max(1, page() - 1))
246
- }
247
-
248
- function nextPage(): void {
249
- page.set(Math.min(pageCount(), page() + 1))
250
- }
245
+ const { previousPage, nextPage } = pageControls(page, pageCount)
251
246
 
252
247
  onMount(() => {
253
248
  void loadCoupons()
@@ -255,29 +250,17 @@ onMount(() => {
255
250
  </script>
256
251
 
257
252
  <div class="space-y-6">
258
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
259
- <div>
260
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Coupons</h1>
261
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native Coupon records, availability, limits, and redemption state.</p>
262
- </div>
263
- <div class="flex flex-wrap gap-2">
264
- <Button variant="secondary" :loading="loading()" @click="loadCoupons()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
265
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'coupons-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
266
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add coupon</Button>
267
- </div>
268
- </header>
253
+ <PageHeader title="Coupons" description="Manage native Coupon records, availability, limits, and redemption state.">
254
+ <div class="flex flex-wrap gap-2">
255
+ <Button variant="secondary" :loading="loading()" @click="loadCoupons()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
256
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'coupons-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
257
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add coupon</Button>
258
+ </div>
259
+ </PageHeader>
269
260
 
270
261
  <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>
271
262
 
272
- <section aria-label="Coupon metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
273
- <template :for="item in metricCards()" :key="item.label">
274
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
275
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
276
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
277
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
278
- </article>
279
- </template>
280
- </section>
263
+ <MetricCards label="Coupon metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
281
264
 
282
265
  <CommerceCouponBreakdown :records="records()" />
283
266
 
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { CommerceCustomerRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
3
3
  import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
4
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
4
5
 
5
6
  const open = useReactiveProp('open', false)
6
7
  const mode = useReactiveProp('mode', 'add')
@@ -72,10 +73,7 @@ function submit(): void {
72
73
  }
73
74
 
74
75
  function errorMessages(): string[] {
75
- return Object.entries(errors()).map(([field, message]) => {
76
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
77
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
78
- })
76
+ return formErrorMessages(errors())
79
77
  }
80
78
  </script>
81
79
 
@@ -6,6 +6,7 @@ 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'
9
10
 
10
11
  interface CustomerIndexResponse {
11
12
  records: CommerceCustomerRecord[]
@@ -243,13 +244,7 @@ async function deleteCustomer(): Promise<void> {
243
244
  }
244
245
  }
245
246
 
246
- function previousPage(): void {
247
- page.set(Math.max(1, page() - 1))
248
- }
249
-
250
- function nextPage(): void {
251
- page.set(Math.min(pageCount(), page() + 1))
252
- }
247
+ const { previousPage, nextPage } = pageControls(page, pageCount)
253
248
 
254
249
  onMount(() => {
255
250
  void loadCustomers()
@@ -257,29 +252,17 @@ onMount(() => {
257
252
  </script>
258
253
 
259
254
  <div class="space-y-6">
260
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
261
- <div>
262
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Customers</h1>
263
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native Customer records, contact details, status, and recorded spend.</p>
264
- </div>
265
- <div class="flex flex-wrap gap-2">
266
- <Button variant="secondary" :loading="loading()" @click="loadCustomers()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
267
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'customers-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
268
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add customer</Button>
269
- </div>
270
- </header>
255
+ <PageHeader title="Customers" description="Manage native Customer records, contact details, status, and recorded spend.">
256
+ <div class="flex flex-wrap gap-2">
257
+ <Button variant="secondary" :loading="loading()" @click="loadCustomers()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
258
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'customers-' + new Date().toISOString().slice(0, 10) + '.csv'"><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 customer</Button>
260
+ </div>
261
+ </PageHeader>
271
262
 
272
263
  <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>
273
264
 
274
- <section aria-label="Customer metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
275
- <template :for="item in metricCards()" :key="item.label">
276
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
277
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
278
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
279
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
280
- </article>
281
- </template>
282
- </section>
265
+ <MetricCards label="Customer metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
283
266
 
284
267
  <section class="space-y-3">
285
268
  <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { GiftCardRecord, GiftCardStatus } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
3
3
  import { minorToInput, moneyInputError, moneyInputStep, parseMoneyInput } from '@stacksjs/commerce/money'
4
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
4
5
 
5
6
  const open = useReactiveProp('open', false)
6
7
  const mode = useReactiveProp('mode', 'add')
@@ -148,10 +149,7 @@ function submit(): void {
148
149
  }
149
150
 
150
151
  function errorMessages(): string[] {
151
- return Object.entries(errors()).map(([field, message]) => {
152
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
153
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
154
- })
152
+ return formErrorMessages(errors())
155
153
  }
156
154
  </script>
157
155
 
@@ -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 { minorToInput } from '@stacksjs/commerce/money'
10
+ import { pageControls } from '../../../../functions/dashboard-list'
10
11
 
11
12
  interface GiftCardIndexResponse {
12
13
  records: GiftCardRecord[]
@@ -257,13 +258,7 @@ async function deleteGiftCard(): Promise<void> {
257
258
  }
258
259
  }
259
260
 
260
- function previousPage(): void {
261
- page.set(Math.max(1, page() - 1))
262
- }
263
-
264
- function nextPage(): void {
265
- page.set(Math.min(pageCount(), page() + 1))
266
- }
261
+ const { previousPage, nextPage } = pageControls(page, pageCount)
267
262
 
268
263
  onMount(() => {
269
264
  void loadGiftCards()
@@ -271,29 +266,17 @@ onMount(() => {
271
266
  </script>
272
267
 
273
268
  <div class="space-y-6">
274
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
275
- <div>
276
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Gift Cards</h1>
277
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage native GiftCard balances, recipients, delivery, and lifecycle state.</p>
278
- </div>
279
- <div class="flex flex-wrap gap-2">
280
- <Button variant="secondary" :loading="loading()" @click="loadGiftCards()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
281
- <Button tag="a" variant="secondary" :href="exportHref()" :download="'gift-cards-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
282
- <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add gift card</Button>
283
- </div>
284
- </header>
269
+ <PageHeader title="Gift Cards" description="Manage native GiftCard balances, recipients, delivery, and lifecycle state.">
270
+ <div class="flex flex-wrap gap-2">
271
+ <Button variant="secondary" :loading="loading()" @click="loadGiftCards()"><span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
272
+ <Button tag="a" variant="secondary" :href="exportHref()" :download="'gift-cards-' + new Date().toISOString().slice(0, 10) + '.csv'"><span class="h-4 w-4 i-hugeicons-download-04"></span>Export filtered</Button>
273
+ <Button @click="openAdd()"><span class="h-4 w-4 i-hugeicons-add-01"></span>Add gift card</Button>
274
+ </div>
275
+ </PageHeader>
285
276
 
286
277
  <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>
287
278
 
288
- <section aria-label="Gift card metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-4">
289
- <template :for="item in metricCards()" :key="item.label">
290
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
291
- <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
292
- <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
293
- <p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
294
- </article>
295
- </template>
296
- </section>
279
+ <MetricCards label="Gift card metrics" columns="grid-cols-2 lg:grid-cols-4" :items="metricCards()" />
297
280
 
298
281
  <CommerceGiftCardBreakdown :summary="summary()" />
299
282
 
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import type { ManufacturerRecord } from '../../../../app/Actions/Dashboard/Commerce/manufacturer-records'
3
+ import { formErrorMessages } from '../../../../functions/dashboard-form'
3
4
 
4
5
  const open = useReactiveProp('open', false)
5
6
  const mode = useReactiveProp('mode', 'add')
@@ -41,10 +42,7 @@ function submit(): void {
41
42
  }
42
43
 
43
44
  function errorMessages(): string[] {
44
- return Object.entries(errors()).map(([field, message]) => {
45
- const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
46
- return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
47
- })
45
+ return formErrorMessages(errors())
48
46
  }
49
47
  </script>
50
48