@stacksjs/defaults 0.75.233 → 0.75.237

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 (56) hide show
  1. package/ai/skills/stacks-auth/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  3. package/ai/skills/stacks-database/SKILL.md +1 -1
  4. package/ai/skills/stacks-deploy/SKILL.md +3 -2
  5. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  6. package/ai/skills/stacks-native/CATALOG.md +12 -12
  7. package/ai/skills/stacks-realtime/SKILL.md +16 -2
  8. package/ai/skills/stacks-router/SKILL.md +8 -0
  9. package/ai/skills/stacks-testing/SKILL.md +5 -0
  10. package/app/Actions/Monitoring/error-provider.ts +129 -12
  11. package/app/Models/Error.ts +21 -0
  12. package/ide/vscode/package.json +1 -1
  13. package/package.json +2 -2
  14. package/resources/components/Dashboard/Auth/AuthCard.stx +29 -0
  15. package/resources/components/Dashboard/Auth/ForgotPassword.stx +65 -79
  16. package/resources/components/Dashboard/Auth/Login.stx +73 -82
  17. package/resources/components/Dashboard/Auth/Register.stx +121 -135
  18. package/resources/components/Dashboard/Auth/ResetPassword.stx +88 -102
  19. package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +11 -15
  20. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -20
  21. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -20
  22. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -20
  23. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -20
  24. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +13 -24
  25. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -20
  26. package/resources/components/Dashboard/Commerce/CommerceOverviewDashboard.stx +17 -21
  27. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +7 -19
  28. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +4 -16
  29. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -20
  30. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +7 -18
  31. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +13 -24
  32. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +13 -24
  33. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +7 -18
  34. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +7 -13
  35. package/resources/components/Dashboard/Commerce/Delivery/DeliveryOverviewDashboard.stx +12 -16
  36. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +7 -13
  37. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +7 -13
  38. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +7 -13
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +7 -13
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +7 -13
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +7 -13
  42. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -20
  43. package/resources/components/Dashboard/Commerce/PrintLogsDashboard.stx +7 -19
  44. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +12 -16
  45. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +12 -16
  46. package/resources/components/Dashboard/Deployments/DeploymentList.stx +17 -21
  47. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +6 -10
  48. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +6 -10
  49. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +9 -13
  50. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +6 -10
  51. package/resources/components/Dashboard/Queue/QueueDashboard.stx +12 -16
  52. package/resources/components/Dashboard/Realtime/RealtimeDashboard.stx +6 -10
  53. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +6 -10
  54. package/resources/components/Dashboard/UI/MetricCards.stx +32 -0
  55. package/resources/components/Dashboard/UI/PageHeader.stx +25 -66
  56. package/resources/components/Dashboard/UI/Table.stx +79 -6
@@ -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">
@@ -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
 
@@ -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
 
@@ -171,16 +171,12 @@ onMount(() => {
171
171
  </script>
172
172
 
173
173
  <div class="space-y-6">
174
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
175
- <div>
176
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Abandoned Carts</h1>
177
- <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>
178
- </div>
179
- <div class="flex gap-2">
180
- <Button :loading="loading()" variant="secondary" @click="loadCarts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
181
- <Button @click="openCompose()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-mail-send-01"></span>New recovery campaign</Button>
182
- </div>
183
- </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>
184
180
 
185
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>
186
182
 
@@ -280,16 +280,12 @@ onMount(() => {
280
280
  </script>
281
281
 
282
282
  <div class="space-y-6">
283
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
284
- <div>
285
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Campaigns</h1>
286
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage Campaign records with real audience and delivery aggregates.</p>
287
- </div>
288
- <div class="flex gap-2">
289
- <Button :loading="loading()" variant="secondary" @click="loadCampaigns()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
290
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add campaign</Button>
291
- </div>
292
- </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>
293
289
 
294
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>
295
291
 
@@ -202,19 +202,15 @@ onMount(() => {
202
202
  </script>
203
203
 
204
204
  <div class="space-y-6">
205
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
206
- <div>
207
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Email lists</h1>
208
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage EmailList records, signup policy, and real membership totals.</p>
209
- </div>
210
- <div class="flex gap-2">
211
- <Button :loading="loading()" variant="secondary" @click="loadLists()">
212
- <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
213
- Refresh
214
- </Button>
215
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add list</Button>
216
- </div>
217
- </header>
205
+ <PageHeader title="Email lists" description="Manage EmailList records, signup policy, and real membership totals.">
206
+ <div class="flex gap-2">
207
+ <Button :loading="loading()" variant="secondary" @click="loadLists()">
208
+ <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
209
+ Refresh
210
+ </Button>
211
+ <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add list</Button>
212
+ </div>
213
+ </PageHeader>
218
214
 
219
215
  <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">
220
216
  <span>{{ loadError() }}</span>
@@ -195,16 +195,12 @@ onMount(() => {
195
195
  </script>
196
196
 
197
197
  <div class="space-y-6">
198
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
199
- <div>
200
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Social posts</h1>
201
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create and schedule SocialPost records with persisted User ownership and platform metrics.</p>
202
- </div>
203
- <div class="flex gap-2">
204
- <Button :loading="loading()" variant="secondary" @click="loadPosts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
205
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add post</Button>
206
- </div>
207
- </header>
198
+ <PageHeader title="Social posts" description="Create and schedule SocialPost records with persisted User ownership and platform metrics.">
199
+ <div class="flex gap-2">
200
+ <Button :loading="loading()" variant="secondary" @click="loadPosts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
201
+ <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add post</Button>
202
+ </div>
203
+ </PageHeader>
208
204
 
209
205
  <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="loadPosts()">Retry</Button></div>
210
206
  <div :if="summary().failed > 0" class="flex gap-3 items-start p-4 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span class="shrink-0 mt-0.5 h-5 w-5 i-hugeicons-alert-02"></span><p>{{ summary().failed }} {{ summary().failed === 1 ? 'post has' : 'posts have' }} a failed status and may need review.</p></div>
@@ -82,22 +82,18 @@ onMount(() => {
82
82
  </script>
83
83
 
84
84
  <div class="space-y-6">
85
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
86
- <div>
87
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Queue</h1>
88
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Recorded queue state and current-process worker metrics.</p>
89
- </div>
90
- <div class="flex gap-2 items-center">
91
- <span :class="'inline-flex items-center px-2.5 py-1 font-medium text-xs rounded-full ' + (queueConnected() ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950' : 'text-red-700 bg-red-100 dark:text-red-300 dark:bg-red-950')">
92
- <span :class="'mr-1.5 h-1.5 w-1.5 rounded-full ' + (queueConnected() ? 'bg-green-500' : 'bg-red-500')"></span>
93
- {{ queueConnected() ? 'Queue connected' : 'Queue unavailable' }}
94
- </span>
95
- <Button variant="secondary" :loading="isLoading()" @click="loadQueue">
96
- <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
97
- Refresh
98
- </Button>
99
- </div>
100
- </header>
85
+ <PageHeader title="Queue" description="Recorded queue state and current-process worker metrics.">
86
+ <div class="flex gap-2 items-center">
87
+ <span :class="'inline-flex items-center px-2.5 py-1 font-medium text-xs rounded-full ' + (queueConnected() ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950' : 'text-red-700 bg-red-100 dark:text-red-300 dark:bg-red-950')">
88
+ <span :class="'mr-1.5 h-1.5 w-1.5 rounded-full ' + (queueConnected() ? 'bg-green-500' : 'bg-red-500')"></span>
89
+ {{ queueConnected() ? 'Queue connected' : 'Queue unavailable' }}
90
+ </span>
91
+ <Button variant="secondary" :loading="isLoading()" @click="loadQueue">
92
+ <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
93
+ Refresh
94
+ </Button>
95
+ </div>
96
+ </PageHeader>
101
97
 
102
98
  <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>
103
99
 
@@ -124,16 +124,12 @@ onDestroy(() => {
124
124
  </script>
125
125
 
126
126
  <div class="space-y-6">
127
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
128
- <div>
129
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Realtime</h1>
130
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Recorded Websocket model events and a native connection inspector.</p>
131
- </div>
132
- <Button variant="secondary" :loading="isLoading()" @click="loadRealtime">
133
- <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
134
- Refresh
135
- </Button>
136
- </header>
127
+ <PageHeader title="Realtime" description="Recorded Websocket model events and a native connection inspector.">
128
+ <Button variant="secondary" :loading="isLoading()" @click="loadRealtime">
129
+ <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
130
+ Refresh
131
+ </Button>
132
+ </PageHeader>
137
133
 
138
134
  <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>
139
135
 
@@ -94,16 +94,12 @@ onMount(() => {
94
94
  </script>
95
95
 
96
96
  <div class="space-y-6">
97
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
98
- <div>
99
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Releases</h1>
100
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Release model records and recorded download totals.</p>
101
- </div>
102
- <Button variant="secondary" :loading="isLoading()" @click="loadReleases">
103
- <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
104
- Refresh
105
- </Button>
106
- </header>
97
+ <PageHeader title="Releases" description="Release model records and recorded download totals.">
98
+ <Button variant="secondary" :loading="isLoading()" @click="loadReleases">
99
+ <span :if="!isLoading()" class="h-4 w-4 i-hugeicons-refresh"></span>
100
+ Refresh
101
+ </Button>
102
+ </PageHeader>
107
103
 
108
104
  <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>
109
105
 
@@ -0,0 +1,32 @@
1
+ <script client>
2
+ /**
3
+ * The metric strip above a dashboard list.
4
+ *
5
+ * Twenty-two dashboards carried this markup verbatim (stacksjs/stacks#2917):
6
+ * the same card, the same classes, the same three lines. Five of them omit
7
+ * `detail`, which is the only variation and is why it is optional here.
8
+ *
9
+ * It renders the <section> rather than only the cards, which is not a
10
+ * stylistic choice. stx wraps every component render in a
11
+ * `data-stx-component-wrapper` div, so a component that emitted only the
12
+ * cards would become ONE child of the caller's grid and every card would
13
+ * stack inside a single cell. Owning the grid keeps the articles as its
14
+ * direct children, where the column classes can reach them.
15
+ *
16
+ * `columns` is a prop because the counts genuinely differ per screen: four,
17
+ * five, three, and two different responsive ramps across the callers.
18
+ */
19
+ const items = useReactiveProp('items', [] as Array<{ label: string, value: string, detail?: string }>)
20
+ const label = useReactiveProp('label', 'Metrics')
21
+ const columns = useReactiveProp('columns', 'grid-cols-2 lg:grid-cols-4')
22
+ </script>
23
+
24
+ <section :aria-label="label()" :class="`grid gap-3 ${columns()}`">
25
+ <template :for="item in items()" :key="item.label">
26
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
27
+ <p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p>
28
+ <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
29
+ <p :if="item.detail" class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p>
30
+ </article>
31
+ </template>
32
+ </section>
@@ -1,69 +1,28 @@
1
- <script server>
2
- interface PageHeaderProps {
3
- title?: string
4
- description?: string
5
- backLink?: string
6
- backLabel?: string
7
- }
8
-
9
- const { title = '', description = '', backLink = '', backLabel = 'Back' } = defineProps<PageHeaderProps>()
1
+ <script client>
2
+ /**
3
+ * The title block at the top of a dashboard screen.
4
+ *
5
+ * Thirty-five screens carried the same header, the same heading pair and the
6
+ * same two sets of classes (stacksjs/stacks#2917).
7
+ *
8
+ * The actions stay in the caller, inside the slot, and so does their wrapper.
9
+ * That is deliberate: the wrapper differs six ways across the callers (`flex
10
+ * gap-2`, `flex flex-wrap gap-2`, `flex gap-2 items-center` and three more),
11
+ * and a prop for it would be a class string passed through a component,
12
+ * which is worse than leaving the div where its author put it.
13
+ *
14
+ * Twenty-three other screens have a header that looks like this one and is
15
+ * shaped differently inside. They are deliberately NOT converted; see the
16
+ * issue for the count and the reason.
17
+ */
18
+ const title = useReactiveProp('title', '')
19
+ const description = useReactiveProp('description', '')
10
20
  </script>
11
- <div class="mb-6">
12
- <!-- Back link -->
13
- @if(backLink)
14
- <a
15
- href="{{ backLink }}"
16
- class="inline-flex gap-1.5 items-center mb-3 text-sm text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors"
17
- >
18
- <div class="h-4 w-4 i-hugeicons-arrow-left-01"></div>
19
- <span>{{ backLabel }}</span>
20
- </a>
21
- @endif
22
-
23
- <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
24
- <div class="flex-1 min-w-0">
25
- <!-- Title row -->
26
- <div class="flex gap-3 items-center">
27
- <slot name="icon" />
28
- <div>
29
- <h1 class="font-semibold text-2xl text-stone-900 truncate dark:text-white">
30
- {{ title }}
31
- </h1>
32
- @if(description)
33
- <p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">
34
- {{ description }}
35
- </p>
36
- @endif
37
- </div>
38
- </div>
39
-
40
- <!-- Tags/badges slot -->
41
- @if($slots.tags)
42
- <div class="flex flex-wrap gap-2 mt-2">
43
- <slot name="tags" />
44
- </div>
45
- @endif
46
- </div>
47
21
 
48
- <!-- Actions -->
49
- @if($slots.actions)
50
- <div class="flex flex-shrink-0 gap-2 items-center">
51
- <slot name="actions" />
52
- </div>
53
- @endif
22
+ <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
23
+ <div>
24
+ <h1 class="font-bold text-2xl text-gray-900 dark:text-white">{{ title() }}</h1>
25
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">{{ description() }}</p>
54
26
  </div>
55
-
56
- <!-- Tabs slot -->
57
- @if($slots.tabs)
58
- <div class="mt-4 border-b border-stone-200 dark:border-neutral-700">
59
- <slot name="tabs" />
60
- </div>
61
- @endif
62
-
63
- <!-- Filters slot -->
64
- @if($slots.filters)
65
- <div class="mt-4">
66
- <slot name="filters" />
67
- </div>
68
- @endif
69
- </div>
27
+ <slot />
28
+ </header>
@@ -1,4 +1,5 @@
1
1
  <script client>
2
+ import { formatCurrency } from '@stacksjs/commerce/money'
2
3
  /**
3
4
  * The data table.
4
5
  *
@@ -28,7 +29,43 @@ interface DashboardTableColumn {
28
29
  * formatter function because props cross an attribute boundary, where a
29
30
  * function does not survive.
30
31
  */
31
- type?: 'text' | 'number' | 'currency' | 'date' | 'boolean'
32
+ type?: 'text' | 'number' | 'currency' | 'date' | 'boolean' | 'badge'
33
+ /**
34
+ * For a `badge` column: the classes each value wears. A value with no
35
+ * entry gets a neutral pill, so a new status never renders unstyled.
36
+ *
37
+ * A map rather than a function because props cross an attribute boundary,
38
+ * which a function does not survive - the same reason `type` is a string.
39
+ */
40
+ badgeClasses?: Record<string, string>
41
+ /**
42
+ * For a `badge` column: what each value reads as, when the stored value is
43
+ * not the word to show. `active` displaying as "Available" is a real case
44
+ * and is not derivable from the value.
45
+ *
46
+ * Without an entry the value is humanised: `on_delivery` becomes
47
+ * "On delivery".
48
+ */
49
+ badgeLabels?: Record<string, string>
50
+ /**
51
+ * What an empty cell reads as. Defaults to `-`.
52
+ *
53
+ * Per column because the right word is specific: a shipping table wants
54
+ * "No minimum" for an absent free-shipping threshold, which is a statement
55
+ * about the rate rather than missing data.
56
+ */
57
+ emptyText?: string
58
+ /**
59
+ * Fixed ISO code for a `currency` column, when every row is the same
60
+ * currency. Defaults to USD, which is what this column did before it
61
+ * could be told otherwise.
62
+ */
63
+ currency?: string
64
+ /**
65
+ * Row field holding the ISO code, when currency varies per row. Wins over
66
+ * `currency` when both are given and the row actually carries one.
67
+ */
68
+ currencyKey?: string
32
69
  width?: string
33
70
  class?: string
34
71
  /** Hidden until switched on in the column menu. */
@@ -106,13 +143,24 @@ function rawValue(row: DashboardTableRow, key: string): any {
106
143
  function displayValue(row: DashboardTableRow, column: DashboardTableColumn): string {
107
144
  const value = rawValue(row, column.key)
108
145
  if (value === null || value === undefined || value === '')
109
- return '-'
146
+ return column.emptyText ?? '-'
110
147
 
111
148
  if (column.type === 'currency') {
112
149
  const amount = Number(value)
113
- return Number.isFinite(amount)
114
- ? amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' })
115
- : String(value)
150
+ if (!Number.isFinite(amount))
151
+ return String(value)
152
+
153
+ // Through @stacksjs/commerce/money, not Intl directly. Stacks stores
154
+ // money in MINOR units, so formatting the stored number as if it were
155
+ // major multiplies every amount by the currency's exponent: a 1200 cent
156
+ // base rate rendered as $1,200.00 instead of $12.00 (stacksjs/stacks#2916).
157
+ //
158
+ // And the exponent is per currency, not always 100 - JPY and KRW have
159
+ // none - so dividing here rather than delegating would be a second bug
160
+ // behind the first. `currencyExponent` is the only thing that knows.
161
+ const perRow = column.currencyKey ? rawValue(row, column.currencyKey) : undefined
162
+ const code = typeof perRow === 'string' && perRow ? perRow : column.currency
163
+ return formatCurrency(amount, code)
116
164
  }
117
165
  if (column.type === 'number') {
118
166
  const amount = Number(value)
@@ -160,6 +208,30 @@ const filtered = derived(() => {
160
208
  })
161
209
 
162
210
  /** Nulls sort last in both directions: "missing" is not a small value. */
211
+ const BADGE_BASE = 'inline-flex px-2.5 py-1 font-medium text-xs ring-1 ring-inset rounded-full'
212
+ const BADGE_NEUTRAL = 'bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-white/5 dark:text-neutral-300 dark:ring-white/10'
213
+
214
+ /** The pill's classes for this row's value, neutral when the value is unmapped. */
215
+ function badgeClass(row: DashboardTableRow, column: DashboardTableColumn): string {
216
+ const key = String(rawValue(row, column.key) ?? '')
217
+ return `${BADGE_BASE} ${column.badgeClasses?.[key] ?? BADGE_NEUTRAL}`
218
+ }
219
+
220
+ /** The pill's text: the mapped label, else the value with its underscores read as spaces. */
221
+ function badgeLabel(row: DashboardTableRow, column: DashboardTableColumn): string {
222
+ const value = rawValue(row, column.key)
223
+ if (value === null || value === undefined || value === '')
224
+ return column.emptyText ?? '-'
225
+
226
+ const key = String(value)
227
+ const mapped = column.badgeLabels?.[key]
228
+ if (mapped)
229
+ return mapped
230
+
231
+ const words = key.replaceAll('_', ' ')
232
+ return words.charAt(0).toUpperCase() + words.slice(1)
233
+ }
234
+
163
235
  function compare(a: any, b: any, type: DashboardTableColumn['type']): number {
164
236
  const aMissing = a === null || a === undefined || a === ''
165
237
  const bMissing = b === null || b === undefined || b === ''
@@ -570,7 +642,8 @@ function cellClass(column: DashboardTableColumn, index: number): string {
570
642
  <template :for="(column, columnIndex) in visibleColumns()">
571
643
  <td :class="cellClass(column, columnIndex)">
572
644
  <slot name="cell" :row="row" :column="column" :value="row[column.key]">
573
- {{ displayValue(row, column) }}
645
+ <span :if="column.type === 'badge'" :class="badgeClass(row, column)">{{ badgeLabel(row, column) }}</span>
646
+ <span :if="column.type !== 'badge'">{{ displayValue(row, column) }}</span>
574
647
  </slot>
575
648
  </td>
576
649
  </template>