@stacksjs/defaults 0.75.262 → 0.75.264

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 (29) hide show
  1. package/ai/skills/stacks-api/SKILL.md +5 -4
  2. package/ai/skills/stacks-browse/SKILL.md +1 -0
  3. package/ai/skills/stacks-browse/scripts/browse.ts +40 -4
  4. package/ai/skills/stacks-email/SKILL.md +12 -8
  5. package/ai/skills/stacks-env/SKILL.md +1 -1
  6. package/ai/skills/stacks-lint/SKILL.md +9 -0
  7. package/ai/skills/stacks-mail/SKILL.md +1 -1
  8. package/ai/skills/stacks-migrations/SKILL.md +32 -1
  9. package/ai/skills/stacks-models/SKILL.md +20 -0
  10. package/ai/skills/stacks-native/CATALOG.md +19 -19
  11. package/ai/skills/stacks-scheduler/SKILL.md +11 -0
  12. package/ai/skills/stacks-testing/SKILL.md +14 -0
  13. package/app/Models/Content/Author.ts +3 -3
  14. package/app/Models/User.ts +4 -4
  15. package/bootstrap.ts +122 -102
  16. package/ide/vscode/package.json +1 -1
  17. package/package.json +4 -4
  18. package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
  19. package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
  20. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
  21. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
  22. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
  23. package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
  24. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
  25. package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
  26. package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
  27. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
  28. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
  29. package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
package/bootstrap.ts CHANGED
@@ -23,7 +23,8 @@
23
23
 
24
24
  import { config, feature } from '@stacksjs/config'
25
25
  import { frameworkPath } from '@stacksjs/path'
26
- import { bundleMounts, DEFAULT_ROUTE_BUNDLE_FEATURES, route } from '@stacksjs/router'
26
+ import { log } from '@stacksjs/logging/runtime'
27
+ import { bundleMounts, DEFAULT_ROUTE_BUNDLE_FEATURES, route, withRouteAdmission } from '@stacksjs/router'
27
28
  import MaintenanceMiddleware from './app/Middleware/Maintenance'
28
29
 
29
30
  // Global maintenance / coming-soon gate. Registered first so the
@@ -71,116 +72,135 @@ function mounts(bundle: keyof typeof DEFAULT_ROUTE_BUNDLE_FEATURES): boolean {
71
72
  return bundleMounts(selection, bundle, feature(DEFAULT_ROUTE_BUNDLE_FEATURES[bundle]))
72
73
  }
73
74
 
74
- // Auth: login, registration, logout, refresh/revoke, passkeys, TOTP 2FA and
75
- // password reset. Split out of dashboard.ts so it can be mounted on its own
76
- // (stacksjs/stacks#2229) — previously the only way to get `/login` was to
77
- // activate `dashboard` and take the storefront, reviews, AI and voice surface
78
- // with it.
79
- //
80
- // Registered BEFORE dashboard.ts, which is where these lived, so the
81
- // first-registration-wins order among framework routes is unchanged.
82
- //
83
- // Deliberately NOT gated on `feature('auth')`, despite the issue asking for
84
- // it: `config/auth.ts` ships in every app with `enabled: true`, so that gate
85
- // is true everywhere and would mount the auth surface — including
86
- // `/generate-two-factor-secret`, `/logout-all` and `/auth/tokens` — in apps
87
- // currently running with `dashboard` off. Widening an app's public surface on
88
- // upgrade is not something a refactor gets to do silently.
89
- if (mounts('auth'))
90
- await route.register(frameworkPath('defaults/routes/auth.ts'))
91
-
92
- // The rest of dashboard.ts: email subscribe, storefront cart/checkout,
93
- // reviews, sitemap, AI, voice, and the admin dashboard's REST surface. Still
94
- // one file and still one gate — splitting auth out was the case with a
95
- // reporter behind it; marketing.ts / commerce.ts / monitoring.ts remain the
96
- // obvious next cuts.
97
- //
98
- // Apps that need only a slice can also define the routes they want directly
99
- // in `routes/api.ts` — first-registration-wins means the user version takes
100
- // priority.
101
- if (mounts('dashboard')) {
102
- await route.register(frameworkPath('defaults/routes/dashboard.ts'))
103
- // JSON endpoints for the dev dashboard UI. Kept separate from the view
104
- // routes above so the data layer is one obvious file to grep.
105
- await route.register(frameworkPath('defaults/routes/dashboard-api.ts'))
106
- // The dev dashboard boots through this file rather than the application
107
- // router's importRoutes() path, so load model-declared useApi routes here too.
75
+ /**
76
+ * Every default bundle this app mounts, registered in one place so the
77
+ * admission check below covers all of them.
78
+ */
79
+ async function registerDefaultBundles(): Promise<void> {
80
+ // Auth: login, registration, logout, refresh/revoke, passkeys, TOTP 2FA and
81
+ // password reset. Split out of dashboard.ts so it can be mounted on its own
82
+ // (stacksjs/stacks#2229) — previously the only way to get `/login` was to
83
+ // activate `dashboard` and take the storefront, reviews, AI and voice surface
84
+ // with it.
85
+ //
86
+ // Registered BEFORE dashboard.ts, which is where these lived, so the
87
+ // first-registration-wins order among framework routes is unchanged.
108
88
  //
109
- // Package first, vendored second, matching importRoutes() and start.ts. This
110
- // line used to import the vendored copy only, and nothing re-vendors that
111
- // file: `@stacksjs/orm` publishes `dist/routes.js` and no `routes.ts`, so an
112
- // app kept whatever generator its copy froze at however often it upgraded.
113
- // An old enough copy compares route paths literally, so a generated
114
- // `PATCH /api/sites/{id}` does not recognise a hand-written
115
- // `/api/sites/{siteId}` as the same endpoint and registers alongside it,
116
- // and the hand-written handler's authorization check stops running
117
- // (stacksjs/stacks#2364).
89
+ // Deliberately NOT gated on `feature('auth')`, despite the issue asking for
90
+ // it: `config/auth.ts` ships in every app with `enabled: true`, so that gate
91
+ // is true everywhere and would mount the auth surface — including
92
+ // `/generate-two-factor-secret`, `/logout-all` and `/auth/tokens` — in apps
93
+ // currently running with `dashboard` off. Widening an app's public surface on
94
+ // upgrade is not something a refactor gets to do silently.
95
+ if (mounts('auth'))
96
+ await route.register(frameworkPath('defaults/routes/auth.ts'))
97
+
98
+ // The rest of dashboard.ts: email subscribe, storefront cart/checkout,
99
+ // reviews, sitemap, AI, voice, and the admin dashboard's REST surface. Still
100
+ // one file and still one gate — splitting auth out was the case with a
101
+ // reporter behind it; marketing.ts / commerce.ts / monitoring.ts remain the
102
+ // obvious next cuts.
118
103
  //
119
- // Held in a variable so the specifier resolves at runtime rather than while
120
- // transpiling, where an unresolvable literal would fail this module instead
121
- // of throwing where it can be caught.
122
- const ormRoutesPackage = '@stacksjs/orm/routes'
123
- try {
124
- await import(ormRoutesPackage)
104
+ // Apps that need only a slice can also define the routes they want directly
105
+ // in `routes/api.ts` — first-registration-wins means the user version takes
106
+ // priority.
107
+ if (mounts('dashboard')) {
108
+ await route.register(frameworkPath('defaults/routes/dashboard.ts'))
109
+ // JSON endpoints for the dev dashboard UI. Kept separate from the view
110
+ // routes above so the data layer is one obvious file to grep.
111
+ await route.register(frameworkPath('defaults/routes/dashboard-api.ts'))
112
+ // The dev dashboard boots through this file rather than the application
113
+ // router's importRoutes() path, so load model-declared useApi routes here too.
114
+ //
115
+ // Package first, vendored second, matching importRoutes() and start.ts. This
116
+ // line used to import the vendored copy only, and nothing re-vendors that
117
+ // file: `@stacksjs/orm` publishes `dist/routes.js` and no `routes.ts`, so an
118
+ // app kept whatever generator its copy froze at however often it upgraded.
119
+ // An old enough copy compares route paths literally, so a generated
120
+ // `PATCH /api/sites/{id}` does not recognise a hand-written
121
+ // `/api/sites/{siteId}` as the same endpoint and registers alongside it,
122
+ // and the hand-written handler's authorization check stops running
123
+ // (stacksjs/stacks#2364).
124
+ //
125
+ // Held in a variable so the specifier resolves at runtime rather than while
126
+ // transpiling, where an unresolvable literal would fail this module instead
127
+ // of throwing where it can be caught.
128
+ const ormRoutesPackage = '@stacksjs/orm/routes'
129
+ try {
130
+ await import(ormRoutesPackage)
131
+ }
132
+ catch {
133
+ await import(frameworkPath('orm/routes.ts'))
134
+ }
125
135
  }
126
- catch {
127
- await import(frameworkPath('orm/routes.ts'))
136
+
137
+ // Email webhook + unsubscribe routes (stacksjs/stacks#1881, #1880).
138
+ // Always mounted when the `email` feature is on — the underlying
139
+ // handlers self-disable when their provider credentials aren't
140
+ // configured (they 401 rather than processing). Apps that need a
141
+ // non-default mount path register their own routes in `routes/api.ts`
142
+ // and the framework's mount silently no-ops since user routes
143
+ // register first.
144
+ if (mounts('email')) {
145
+ await route.register(frameworkPath('defaults/routes/email.ts'))
128
146
  }
129
- }
130
147
 
131
- // Email webhook + unsubscribe routes (stacksjs/stacks#1881, #1880).
132
- // Always mounted when the `email` feature is on — the underlying
133
- // handlers self-disable when their provider credentials aren't
134
- // configured (they 401 rather than processing). Apps that need a
135
- // non-default mount path register their own routes in `routes/api.ts`
136
- // and the framework's mount silently no-ops since user routes
137
- // register first.
138
- if (mounts('email')) {
139
- await route.register(frameworkPath('defaults/routes/email.ts'))
140
- }
148
+ // Form-builder public endpoints (`@stacksjs/forms`). Gated on the `forms`
149
+ // feature, which defaults OFF - an app opts in with config/forms.ts
150
+ // (`buddy forms:install`) and only then do the public submit routes exist.
151
+ if (mounts('forms')) {
152
+ await route.register(frameworkPath('defaults/routes/forms.ts'))
153
+ }
141
154
 
142
- // Form-builder public endpoints (`@stacksjs/forms`). Gated on the `forms`
143
- // feature, which defaults OFF - an app opts in with config/forms.ts
144
- // (`buddy forms:install`) and only then do the public submit routes exist.
145
- if (mounts('forms')) {
146
- await route.register(frameworkPath('defaults/routes/forms.ts'))
147
- }
155
+ // Courier delivery endpoints: position ingest and the stop/route lifecycle a
156
+ // courier's device drives. Gated with the rest of the commerce surface, since
157
+ // they are meaningless without the delivery models behind them.
158
+ if (mounts('delivery')) {
159
+ await route.register(frameworkPath('defaults/routes/delivery.ts'))
160
+ }
148
161
 
149
- // Courier delivery endpoints: position ingest and the stop/route lifecycle a
150
- // courier's device drives. Gated with the rest of the commerce surface, since
151
- // they are meaningless without the delivery models behind them.
152
- if (mounts('delivery')) {
153
- await route.register(frameworkPath('defaults/routes/delivery.ts'))
154
- }
162
+ // The payment provider's webhook, which applies payments and refunds to
163
+ // orders. Gated with the commerce surface; it answers 401 until the driver's
164
+ // webhook secret is configured.
165
+ if (mounts('payments')) {
166
+ await route.register(frameworkPath('defaults/routes/payments.ts'))
167
+ }
155
168
 
156
- // The payment provider's webhook, which applies payments and refunds to
157
- // orders. Gated with the commerce surface; it answers 401 until the driver's
158
- // webhook secret is configured.
159
- if (mounts('payments')) {
160
- await route.register(frameworkPath('defaults/routes/payments.ts'))
161
- }
169
+ // The page a Paddle payment link opens: Paddle has no hosted checkout, so
170
+ // its default payment link has to point at a page of the app's that loads
171
+ // Paddle.js. Mounted while the app pays through Paddle, as part of the
172
+ // `payments` bundle when the app names its bundles.
173
+ const paymentDriverName = (config as { payment?: { driver?: string } }).payment?.driver
174
+ if (paymentDriverName === 'paddle' && (selection?.explicit ? selection.bundles.has('payments') : true)) {
175
+ await route.register(frameworkPath('defaults/routes/paddle.ts'))
176
+ }
162
177
 
163
- // The page a Paddle payment link opens: Paddle has no hosted checkout, so
164
- // its default payment link has to point at a page of the app's that loads
165
- // Paddle.js. Mounted while the app pays through Paddle, as part of the
166
- // `payments` bundle when the app names its bundles.
167
- const paymentDriverName = (config as { payment?: { driver?: string } }).payment?.driver
168
- if (paymentDriverName === 'paddle' && (selection?.explicit ? selection.bundles.has('payments') : true)) {
169
- await route.register(frameworkPath('defaults/routes/paddle.ts'))
178
+ // Social sign-in: `/auth/{provider}` + `/auth/{provider}/callback`
179
+ // (stacksjs/stacks#2276). An opt-in bundle, NOT part of the implicit default
180
+ // set or `all` — OAuth callback URLs in an app that configured no provider
181
+ // are surface for nothing. So `mounts()` does not apply here: an app that
182
+ // NAMED its bundles decides outright (`social` listed → on, absent → off),
183
+ // and an app that said nothing gets it exactly when a provider is actually
184
+ // configured in config/services.ts — configuring GitHub and finding
185
+ // /auth/github dead would be the puzzle, not the mount.
186
+ const { configuredSocialProviders } = await import('@stacksjs/socials')
187
+ const mountSocial = selection?.explicit
188
+ ? selection.bundles.has('social')
189
+ : configuredSocialProviders().length > 0
190
+ if (mountSocial) {
191
+ await route.register(frameworkPath('defaults/routes/socials.ts'))
192
+ }
170
193
  }
171
194
 
172
- // Social sign-in: `/auth/{provider}` + `/auth/{provider}/callback`
173
- // (stacksjs/stacks#2276). An opt-in bundle, NOT part of the implicit default
174
- // set or `all` — OAuth callback URLs in an app that configured no provider
175
- // are surface for nothing. So `mounts()` does not apply here: an app that
176
- // NAMED its bundles decides outright (`social` listed → on, absent → off),
177
- // and an app that said nothing gets it exactly when a provider is actually
178
- // configured in config/services.ts — configuring GitHub and finding
179
- // /auth/github dead would be the puzzle, not the mount.
180
- const { configuredSocialProviders } = await import('@stacksjs/socials')
181
- const mountSocial = selection?.explicit
182
- ? selection.bundles.has('social')
183
- : configuredSocialProviders().length > 0
184
- if (mountSocial) {
185
- await route.register(frameworkPath('defaults/routes/socials.ts'))
195
+ // Register the bundles, withholding any route whose framework action depends
196
+ // on a model this app excluded from its schema and whose table its database
197
+ // does not have. Those routes could only answer 500 (stacksjs/stacks#2943); now
198
+ // they are absent, and boot says which and why. When the table list cannot be
199
+ // read, nothing is withheld.
200
+ const { frameworkRouteAdmission } = await import('@stacksjs/database')
201
+ const withheld = await withRouteAdmission(await frameworkRouteAdmission(), registerDefaultBundles)
202
+ if (withheld.length > 0) {
203
+ const shown = withheld.slice(0, 5).map(r => `${r.method} ${r.path} -> ${r.handler} needs ${r.reason}`)
204
+ const more = withheld.length > shown.length ? `\n ... and ${withheld.length - shown.length} more` : ''
205
+ log.warn(`[Routes] Not registering ${withheld.length} framework route(s) whose models are not in this app's schema (database.models.includeFrameworkDefaults is false) and whose tables do not exist:\n ${shown.join('\n ')}${more}\nPublish a model with \`buddy publish model <Name>\` or set includeFrameworkDefaults to serve them.`)
186
206
  }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.262",
5
+ "version": "0.75.264",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.262",
5
+ "version": "0.75.264",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -45,9 +45,9 @@
45
45
  "dependencies": {
46
46
  "@iconify-json/f7": "^1.2.2",
47
47
  "@iconify-json/hugeicons": "^1.2.35",
48
- "@stacksjs/mobile": "0.75.262",
49
- "@stacksjs/sanitizer": "0.2.661",
50
- "@stacksjs/stx": "0.2.661",
48
+ "@stacksjs/mobile": "0.75.264",
49
+ "@stacksjs/sanitizer": "0.2.663",
50
+ "@stacksjs/stx": "0.2.663",
51
51
  "@xterm/addon-fit": "^0.11.0",
52
52
  "@xterm/xterm": "^6.0.0",
53
53
  "ts-qr-codes": "^0.1.8"
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
3
5
  import { authStore } from '../../../../views/dashboard/stores/auth'
@@ -59,11 +61,31 @@ function tokenStatus(token: AccessTokenRow): 'active' | 'expired' {
59
61
  return new Date(token.expires_at).getTime() <= Date.now() ? 'expired' : 'active'
60
62
  }
61
63
 
62
- function statusClass(token: AccessTokenRow): string {
63
- return tokenStatus(token) === 'active'
64
- ? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40'
65
- : 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40'
66
- }
64
+ const TOKEN_COLUMNS = [
65
+ { key: 'name', label: 'Token', sortable: true, subtitleKey: 'createdLabel', class: 'font-medium text-neutral-900 dark:text-white' },
66
+ { key: 'scopesLabel', label: 'Scopes', slotted: true },
67
+ {
68
+ key: 'status',
69
+ label: 'Status',
70
+ type: 'badge' as const,
71
+ sortable: true,
72
+ badgeClasses: {
73
+ active: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
74
+ expired: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
75
+ },
76
+ badgeLabels: { active: 'Active', expired: 'Expired' },
77
+ },
78
+ { key: 'expires_at', label: 'Expires', align: 'right' as const, sortable: true, slotted: true, searchable: false },
79
+ ]
80
+
81
+ const tokenRows = derived(() => tokens().map(token => ({
82
+ ...token,
83
+ createdLabel: `Created ${formatDate(token.created_at)}`,
84
+ // Searchable text for the scope pills the cell draws.
85
+ scopesLabel: token.scopes.join(' '),
86
+ status: tokenStatus(token),
87
+ expiresLabel: formatDate(token.expires_at),
88
+ })))
67
89
 
68
90
  function formatDate(value: string | null): string {
69
91
  if (!value)
@@ -270,38 +292,36 @@ onMount(() => {
270
292
  </template>
271
293
  </EmptyState>
272
294
 
273
- <div :if="!loading() && !authenticationRequired() && tokens().length > 0" class="overflow-x-auto">
274
- <table class="w-full">
275
- <thead class="bg-neutral-50 dark:bg-neutral-800/50">
276
- <tr>
277
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Token</th>
278
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Scopes</th>
279
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
280
- <th class="px-4 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Expires</th>
281
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Action</th>
282
- </tr>
283
- </thead>
284
- <tbody class="divide-neutral-100 divide-y dark:divide-neutral-700">
285
- <tr :for="token in tokens()">
286
- <td class="pl-5 pr-4 py-3.5">
287
- <p class="font-medium text-neutral-900 text-sm dark:text-white">{{ token.name }}</p>
288
- <p class="mt-0.5 text-neutral-500 text-xs dark:text-neutral-400">Created {{ formatDate(token.created_at) }}</p>
289
- </td>
290
- <td class="px-4 py-3.5">
291
- <div class="flex flex-wrap gap-1">
292
- <span :for="scope in token.scopes" class="inline-flex px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded">{{ scope }}</span>
293
- </div>
294
- </td>
295
- <td class="px-4 py-3.5"><span :class="'inline-flex px-2 py-0.5 font-medium text-xs capitalize rounded ' + statusClass(token)">{{ tokenStatus(token) }}</span></td>
296
- <td class="px-4 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(token.expires_at) }}</td>
297
- <td class="pr-5 py-3.5 text-right whitespace-nowrap">
298
- <Button variant="danger" size="sm" :loading="revoking() === token.id" @click="confirmingRevoke.set(token.id)">
299
- {{ revoking() === token.id ? 'Revoking...' : 'Revoke' }}
300
- </Button>
301
- </td>
302
- </tr>
303
- </tbody>
304
- </table>
295
+ {{--
296
+ The page keeps its loading, sign-in and empty states; the table brings
297
+ sorting and paging. Scopes are slotted for their pills and expiry for
298
+ "Never", so it sorts on the date itself.
299
+ --}}
300
+ <div :if="!loading() && !authenticationRequired() && tokens().length > 0">
301
+ <Table :columns="TOKEN_COLUMNS" :data="tokenRows()" paginated :page-size="25">
302
+ <template #cell="{ row, column }">
303
+ <template :if="column.key === 'scopesLabel'">
304
+ <div class="flex flex-wrap gap-1">
305
+ <template :for="scope in row.scopes" :key="scope">
306
+ <span class="inline-flex px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded" x-text="scope"></span>
307
+ </template>
308
+ </div>
309
+ </template>
310
+ <template :else>
311
+ <span class="text-neutral-500 text-xs whitespace-nowrap dark:text-neutral-400" x-text="row.expiresLabel"></span>
312
+ </template>
313
+ </template>
314
+ <template #actions="{ row }">
315
+ <div class="text-right whitespace-nowrap">
316
+ <template :if="revoking() === row.id">
317
+ <Button variant="danger" size="sm" loading>Revoking...</Button>
318
+ </template>
319
+ <template :else>
320
+ <Button variant="danger" size="sm" @click="confirmingRevoke.set(row.id)">Revoke</Button>
321
+ </template>
322
+ </div>
323
+ </template>
324
+ </Table>
305
325
  </div>
306
326
  </Card>
307
327
 
@@ -1,13 +1,27 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { BillingTransaction } from '../../../../functions/billing/dashboard'
3
5
 
4
6
  const transactions = useReactiveProp('transactions', [] as BillingTransaction[])
5
7
 
6
- function amount(transaction: BillingTransaction): string {
7
- if (transaction.amount === null)
8
- return 'Not reported'
9
- return `${new Intl.NumberFormat().format(transaction.amount)} minor units`
10
- }
8
+ const COLUMNS = [
9
+ { key: 'name', label: 'Transaction', sortable: true, emptyText: 'Unnamed transaction', subtitleKey: 'description', class: 'font-medium text-gray-900 max-w-lg dark:text-white' },
10
+ {
11
+ key: 'typeLabel',
12
+ label: 'Type',
13
+ type: 'badge' as const,
14
+ sortable: true,
15
+ },
16
+ { key: 'amountLabel', label: 'Amount', class: 'whitespace-nowrap', searchable: false },
17
+ { key: 'providerId', label: 'Provider reference', emptyText: 'Not reported', class: 'font-mono text-xs' },
18
+ ]
19
+
20
+ const rows = derived(() => transactions().map(transaction => ({
21
+ ...transaction,
22
+ typeLabel: (transaction.type || 'unknown').replace(/\b\w/g, letter => letter.toUpperCase()),
23
+ amountLabel: transaction.amount === null ? 'Not reported' : `${new Intl.NumberFormat().format(transaction.amount)} minor units`,
24
+ })))
11
25
  </script>
12
26
 
13
27
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
@@ -19,32 +33,12 @@ function amount(transaction: BillingTransaction): string {
19
33
  <span class="h-5 w-5 text-gray-400 i-hugeicons-transaction-history"></span>
20
34
  </header>
21
35
 
22
- <div :if="transactions().length === 0" class="px-6 py-14 text-center">
23
- <span class="inline-block h-7 w-7 text-gray-400 i-hugeicons-receipt-dollar"></span>
24
- <p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No transactions recorded</p>
25
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Successful persisted billing transactions will appear here.</p>
26
- </div>
27
-
28
- <div :else class="overflow-x-auto">
29
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
30
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
31
- <tr>
32
- <th scope="col" class="px-5 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Transaction</th>
33
- <th scope="col" class="px-5 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Type</th>
34
- <th scope="col" class="px-5 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Amount</th>
35
- <th scope="col" class="px-5 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Provider reference</th>
36
- </tr>
37
- </thead>
38
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
39
- <template :for="transaction in transactions()" :key="transaction.id">
40
- <tr>
41
- <td class="px-5 py-4 max-w-lg"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ transaction.name || 'Unnamed transaction' }}</p><p :if="transaction.description" class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400">{{ transaction.description }}</p></td>
42
- <td class="px-5 py-4"><span class="inline-flex px-2 py-1 capitalize font-medium text-gray-700 text-xs dark:text-neutral-200 bg-gray-100 dark:bg-neutral-700 rounded-full">{{ transaction.type || 'unknown' }}</span></td>
43
- <td class="px-5 py-4 text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200">{{ amount(transaction) }}</td>
44
- <td class="px-5 py-4 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ transaction.providerId || 'Not reported' }}</td>
45
- </tr>
46
- </template>
47
- </tbody>
48
- </table>
49
- </div>
36
+ <Table
37
+ :columns="COLUMNS"
38
+ :data="rows()"
39
+ paginated
40
+ :page-size="25"
41
+ empty-title="No transactions recorded"
42
+ empty-description="Successful persisted billing transactions will appear here."
43
+ />
50
44
  </section>