@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
@@ -5,6 +5,7 @@ import type {
5
5
  } from '../../../../app/Actions/Dashboard/Marketing/marketing-list-records'
6
6
  import { dashboardApi } from '../../../../functions/dashboard-api'
7
7
  import { pushToast } from '../../../../functions/toasts'
8
+ import { pageControls } from '../../../../functions/dashboard-list'
8
9
 
9
10
  interface MarketingListIndexResponse {
10
11
  records: MarketingListRecord[]
@@ -193,15 +194,7 @@ async function deleteList(): Promise<void> {
193
194
  }
194
195
  }
195
196
 
196
- function previousPage(): void {
197
- if (page() > 1)
198
- page.set(page() - 1)
199
- }
200
-
201
- function nextPage(): void {
202
- if (page() < totalPages())
203
- page.set(page() + 1)
204
- }
197
+ const { previousPage, nextPage } = pageControls(page, totalPages)
205
198
 
206
199
  onMount(() => {
207
200
  void loadLists()
@@ -209,19 +202,15 @@ onMount(() => {
209
202
  </script>
210
203
 
211
204
  <div class="space-y-6">
212
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
213
- <div>
214
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Email lists</h1>
215
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage EmailList records, signup policy, and real membership totals.</p>
216
- </div>
217
- <div class="flex gap-2">
218
- <Button :loading="loading()" variant="secondary" @click="loadLists()">
219
- <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
220
- Refresh
221
- </Button>
222
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add list</Button>
223
- </div>
224
- </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>
225
214
 
226
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">
227
216
  <span>{{ loadError() }}</span>
@@ -6,6 +6,7 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Marketing/social-post-records'
7
7
  import { dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
+ import { pageControls } from '../../../../functions/dashboard-list'
9
10
 
10
11
  interface SocialPostIndexResponse {
11
12
  records: SocialPostRecord[]
@@ -186,15 +187,7 @@ async function deletePost(): Promise<void> {
186
187
  }
187
188
  }
188
189
 
189
- function previousPage(): void {
190
- if (page() > 1)
191
- page.set(page() - 1)
192
- }
193
-
194
- function nextPage(): void {
195
- if (page() < totalPages())
196
- page.set(page() + 1)
197
- }
190
+ const { previousPage, nextPage } = pageControls(page, totalPages)
198
191
 
199
192
  onMount(() => {
200
193
  void loadPosts()
@@ -202,16 +195,12 @@ onMount(() => {
202
195
  </script>
203
196
 
204
197
  <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">Social posts</h1>
208
- <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>
209
- </div>
210
- <div class="flex gap-2">
211
- <Button :loading="loading()" variant="secondary" @click="loadPosts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
212
- <Button @click="openAdd()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add post</Button>
213
- </div>
214
- </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>
215
204
 
216
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>
217
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>
@@ -88,7 +88,15 @@ const isRepl = !process.argv[1]
88
88
  // otherwise valid. Postinstall scripts are bootstrap work and must stay independent
89
89
  // of the application runtime preloader.
90
90
  const isPostinstall = process.env.npm_lifecycle_event === 'postinstall'
91
- const skipPreloader = isRepl || isPostinstall || (args.length > 0 && fastCommands.some(cmd => args[0] === cmd || args[0].startsWith(`${cmd}:`)))
91
+ // runAction starts these framework files directly, with flags rather than a
92
+ // Buddy command in argv[2]. They only inspect/release repository metadata.
93
+ // Match the framework's source and published layouts, never an app action
94
+ // which happens to have the same basename and needs its application globals.
95
+ const entrypoint = (process.argv[1] ?? '').replaceAll('\\', '/')
96
+ const repositoryAction = '(?:release|bump|generate/lib-entries)\\.[jt]s'
97
+ const isRepositoryAction = new RegExp(`(?:^|/)storage/framework/core/actions/src/${repositoryAction}$`).test(entrypoint)
98
+ || new RegExp(`(?:^|/)(?:node_modules|pantry)/@stacksjs/actions/(?:src|dist(?:/src)?)/${repositoryAction}$`).test(entrypoint)
99
+ const skipPreloader = isRepl || isPostinstall || isRepositoryAction || (args.length > 0 && fastCommands.some(cmd => args[0] === cmd || args[0].startsWith(`${cmd}:`)))
92
100
 
93
101
  if (!skipPreloader) {
94
102
  // Detect production/deployment commands and set environment accordingly BEFORE loading env files