@stacksjs/defaults 0.75.263 → 0.75.267

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 (23) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +3 -0
  2. package/ai/skills/stacks-browse/scripts/browse.ts +73 -14
  3. package/ai/skills/stacks-chat/SKILL.md +32 -0
  4. package/ai/skills/stacks-native/CATALOG.md +8 -8
  5. package/ide/vscode/package.json +1 -1
  6. package/package.json +4 -4
  7. package/resources/components/Dashboard/App/RequestsOverview.stx +75 -58
  8. package/resources/components/Dashboard/App/SourceInventory.stx +68 -59
  9. package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
  10. package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
  11. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
  12. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
  13. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
  14. package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
  15. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
  16. package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
  17. package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
  18. package/resources/components/Dashboard/Infrastructure/DnsDashboard.stx +46 -28
  19. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
  20. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
  21. package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
  22. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +35 -61
  23. package/resources/components/Dashboard/Teams/TeamPeopleDashboard.stx +127 -143
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import { dashboardApi } from '../../../../functions/dashboard-api'
3
5
 
@@ -44,9 +46,7 @@ const page = derived(() => mode() === 'actions'
44
46
  empty: 'No Action modules were found.',
45
47
  scaffold: 'buddy make:action MyAction',
46
48
  icon: 'i-hugeicons-energy',
47
- detailLabel: 'Method',
48
49
  note: 'Application files override matching framework defaults.',
49
- showOrigin: true,
50
50
  stats: [
51
51
  { key: 'total', label: 'Total Actions' },
52
52
  { key: 'application', label: 'Application Overrides' },
@@ -60,9 +60,7 @@ const page = derived(() => mode() === 'actions'
60
60
  empty: 'No command files in app/Commands.',
61
61
  scaffold: 'buddy make:command my-command',
62
62
  icon: 'i-hugeicons-command-line',
63
- detailLabel: 'Aliases and options',
64
63
  note: 'Every file in app/Commands is a command. The optional app/Commands.ts only orders, aliases or disables them.',
65
- showOrigin: false,
66
64
  stats: [
67
65
  { key: 'total', label: 'Commands' },
68
66
  { key: 'registered', label: 'Registry Entries' },
@@ -90,13 +88,6 @@ function statValue(key: string): number {
90
88
  return Number(stats()[key] || 0)
91
89
  }
92
90
 
93
- function formatDate(value: string): string {
94
- if (!value)
95
- return 'Unknown'
96
- const date = new Date(value)
97
- return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString()
98
- }
99
-
100
91
  function formatAliases(item: SourceItem): string {
101
92
  return item.aliases?.length ? item.aliases.join(', ') : 'None'
102
93
  }
@@ -105,14 +96,67 @@ function formatOptions(item: SourceItem): string {
105
96
  return item.options?.length ? item.options.join(', ') : 'None'
106
97
  }
107
98
 
108
- function methodClass(method = 'ANY'): string {
99
+ /** Read-only methods, writes, and the catch-all, as the hand-rolled pill coloured them. */
100
+ function methodTone(method = 'ANY'): string {
109
101
  if (['GET', 'HEAD', 'OPTIONS'].includes(method))
110
- return 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40'
102
+ return 'read'
111
103
  if (method === 'ANY')
112
- return 'text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800'
113
- return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40'
104
+ return 'any'
105
+ return 'write'
114
106
  }
115
107
 
108
+ const NAME_COLUMN = { key: 'name', label: 'Name', sortable: true, subtitleKey: 'description', class: 'font-medium text-neutral-900 dark:text-white' }
109
+ const PATH_COLUMN = { key: 'path', label: 'Path', sortable: true, class: 'font-mono text-neutral-600 text-xs dark:text-neutral-300' }
110
+ const MODIFIED_COLUMN = { key: 'modifiedAt', label: 'Modified', type: 'datetime' as const, sortable: true, align: 'right' as const, emptyText: 'Unknown', class: 'whitespace-nowrap' }
111
+
112
+ /**
113
+ * Actions show a method and where the file comes from. Commands have one
114
+ * root, so their second column is what can be typed instead.
115
+ */
116
+ const COLUMNS_BY_MODE = {
117
+ actions: [
118
+ NAME_COLUMN,
119
+ {
120
+ key: 'methodTone',
121
+ label: 'Method',
122
+ type: 'badge' as const,
123
+ sortable: true,
124
+ badgeLabelKey: 'methodLabel',
125
+ badgeClasses: {
126
+ read: 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40',
127
+ any: 'text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800',
128
+ write: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
129
+ },
130
+ },
131
+ {
132
+ key: 'origin',
133
+ label: 'Origin',
134
+ type: 'badge' as const,
135
+ sortable: true,
136
+ badgeClasses: {
137
+ Application: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
138
+ Framework: 'text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800',
139
+ },
140
+ },
141
+ PATH_COLUMN,
142
+ MODIFIED_COLUMN,
143
+ ],
144
+ commands: [
145
+ NAME_COLUMN,
146
+ { key: 'aliasesLabel', label: 'Aliases', sortable: true, subtitleKey: 'optionsLabel', class: 'text-neutral-700 text-xs dark:text-neutral-300' },
147
+ PATH_COLUMN,
148
+ MODIFIED_COLUMN,
149
+ ],
150
+ }
151
+
152
+ const itemRows = derived(() => filteredItems().map(item => ({
153
+ ...item,
154
+ methodTone: methodTone(item.method),
155
+ methodLabel: item.method || 'ANY',
156
+ aliasesLabel: `Aliases: ${formatAliases(item)}`,
157
+ optionsLabel: `Options: ${formatOptions(item)}`,
158
+ })))
159
+
116
160
  async function loadInventory(): Promise<void> {
117
161
  loading.set(true)
118
162
  loadError.set('')
@@ -205,50 +249,15 @@ onMount(() => {
205
249
  </label>
206
250
  </div>
207
251
 
208
- <div :if="loading()" class="flex items-center justify-center py-16 text-neutral-500 text-sm dark:text-neutral-400">
209
- <span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>
210
- Reading source files...
211
- </div>
212
-
213
- <EmptyState
214
- :if="!loading() && !loadError() && filteredItems().length === 0"
215
- :icon="page().icon"
216
- :title="search() ? 'No matching source files' : page().empty"
217
- :description="search() ? 'Try a different name, path, method, or alias.' : 'Use the scaffold command to add one through the native framework workflow.'"
252
+ <Table
253
+ :columns="COLUMNS_BY_MODE[mode()]"
254
+ :data="itemRows()"
255
+ :loading="loading()"
256
+ row-key="path"
257
+ paginated
258
+ :page-size="25"
259
+ :empty-title="search() ? 'No matching source files' : page().empty"
260
+ :empty-description="search() ? 'Try a different name, path, method, or alias.' : 'Use the scaffold command to add one through the native framework workflow.'"
218
261
  />
219
-
220
- <div :if="!loading() && filteredItems().length > 0" class="overflow-x-auto">
221
- <table class="w-full">
222
- <thead class="bg-neutral-50 dark:bg-neutral-800/50">
223
- <tr>
224
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Name</th>
225
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">{{ page().detailLabel }}</th>
226
- <th :if="page().showOrigin" class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Origin</th>
227
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Path</th>
228
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Modified</th>
229
- </tr>
230
- </thead>
231
- <tbody class="divide-neutral-100 divide-y dark:divide-neutral-700">
232
- <tr :for="item in filteredItems()">
233
- <td class="pl-5 pr-4 py-3.5">
234
- <p class="font-medium text-neutral-900 text-sm dark:text-white">{{ item.name }}</p>
235
- <p class="mt-0.5 max-w-md text-neutral-500 text-xs dark:text-neutral-400">{{ item.description }}</p>
236
- </td>
237
- <td class="px-4 py-3.5">
238
- <span :if="mode() === 'actions'" :class="'inline-flex px-2 py-0.5 font-medium text-xs rounded ' + methodClass(item.method)">{{ item.method || 'ANY' }}</span>
239
- <div :if="mode() === 'commands'" class="space-y-1 text-xs">
240
- <p class="text-neutral-700 dark:text-neutral-300"><span class="text-neutral-500 dark:text-neutral-400">Aliases:</span> {{ formatAliases(item) }}</p>
241
- <p class="text-neutral-700 dark:text-neutral-300"><span class="text-neutral-500 dark:text-neutral-400">Options:</span> {{ formatOptions(item) }}</p>
242
- </div>
243
- </td>
244
- <td :if="page().showOrigin" class="px-4 py-3.5">
245
- <span :class="'inline-flex px-2 py-0.5 font-medium text-xs rounded ' + (item.origin === 'Application' ? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40' : 'text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800')">{{ item.origin }}</span>
246
- </td>
247
- <td class="px-4 py-3.5 font-mono text-neutral-600 text-xs dark:text-neutral-300">{{ item.path }}</td>
248
- <td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(item.modifiedAt) }}</td>
249
- </tr>
250
- </tbody>
251
- </table>
252
- </div>
253
262
  </Card>
254
263
  </div>
@@ -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>
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { PaymentRecord } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
3
5
  import { formatCurrency } from '@stacksjs/commerce/money'
@@ -6,61 +8,95 @@ const records = useReactiveProp('records', [] as PaymentRecord[])
6
8
  const loading = useReactiveProp('loading', false)
7
9
  const emit = defineEmits()
8
10
 
11
+ /** `partiallyRefunded` and `bank_transfer` as words, each capitalized. */
9
12
  function label(value: string): string {
10
- return value.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
11
- }
12
-
13
- function money(value: number, currency: string): string {
14
- return formatCurrency(value, currency)
15
- }
16
-
17
- function date(value: string): string {
18
- const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
19
- return !value || Number.isNaN(parsed.getTime())
20
- ? 'Unknown'
21
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
13
+ return value.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
22
14
  }
23
15
 
24
- function statusClass(status: string): string {
16
+ /**
17
+ * The status pill's colour, which is a tone rather than the status itself so
18
+ * that every status the ledger has not named still reads as pending, as the
19
+ * hand-rolled pill did.
20
+ */
21
+ function statusTone(status: string): string {
25
22
  if (status === 'completed' || status === 'succeeded')
26
- return 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40'
23
+ return 'settled'
27
24
  if (status === 'failed')
28
- return 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40'
25
+ return 'failed'
29
26
  if (status === 'refunded' || status === 'partiallyRefunded')
30
- return 'text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-950/40'
31
- return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40'
27
+ return 'refunded'
28
+ return 'pending'
29
+ }
30
+
31
+ /**
32
+ * Only the amount is slotted, for its refund line, whose colour says whether
33
+ * the refund was recorded. The rest are values with a second line, which is
34
+ * `subtitleKey`.
35
+ */
36
+ const COLUMNS = [
37
+ { key: 'paymentLabel', label: 'Payment', sortable: true, subtitleKey: 'referenceLabel', class: 'font-medium text-gray-900 dark:text-white' },
38
+ { key: 'billingLabel', label: 'Billing', sortable: true, subtitleKey: 'orderLabel' },
39
+ { key: 'amount', label: 'Amount', sortable: true, slotted: true },
40
+ // Capitalized in `label()` rather than with a class, which would reach the
41
+ // processor line beneath it too.
42
+ { key: 'methodLabel', label: 'Method', sortable: true, subtitleKey: 'processorLabel' },
43
+ {
44
+ key: 'statusTone',
45
+ label: 'Status',
46
+ type: 'badge' as const,
47
+ sortable: true,
48
+ badgeLabelKey: 'statusLabel',
49
+ badgeClasses: {
50
+ settled: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40',
51
+ failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
52
+ refunded: 'text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-950/40',
53
+ pending: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
54
+ },
55
+ },
56
+ { key: 'createdAt', label: 'Created', type: 'datetime' as const, sortable: true, class: 'whitespace-nowrap' },
57
+ ]
58
+
59
+ const ROW_ACTIONS = [
60
+ { action: 'view', label: 'View payment', icon: 'i-hugeicons-view' },
61
+ ]
62
+
63
+ const rows = derived(() => records().map(record => ({
64
+ ...record,
65
+ paymentLabel: `#${record.id}`,
66
+ referenceLabel: record.referenceNumber || record.transactionId || 'No reference',
67
+ billingLabel: record.billingEmail || 'No billing email',
68
+ orderLabel: record.orderId ? `Order #${record.orderId}` : 'No linked order',
69
+ amountLabel: formatCurrency(record.amount, record.currency),
70
+ refundLabel: record.refundAmount > 0 ? `Refunded ${formatCurrency(record.refundAmount, record.currency)}` : '',
71
+ refundMissing: record.refundAmount <= 0 && record.status === 'refunded' && !record.refundRecorded,
72
+ methodLabel: label(record.method),
73
+ processorLabel: record.cardBrand || record.cardLastFour
74
+ ? [record.cardBrand, record.cardLastFour].filter(Boolean).join(' · ')
75
+ : record.paymentProvider || 'No processor details',
76
+ statusTone: statusTone(record.status),
77
+ statusLabel: label(record.status),
78
+ })))
79
+
80
+ function handleRowAction(payload: { action: string, row: PaymentRecord }): void {
81
+ emit(payload.action, payload.row)
32
82
  }
33
83
  </script>
34
84
 
35
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
36
- <div :if="loading() && records().length === 0" class="flex items-center justify-center py-16 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>Loading payments</div>
37
- <div :else-if="records().length === 0" class="px-6 py-16 text-center"><span class="inline-block h-8 w-8 text-gray-400 i-hugeicons-credit-card"></span><p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No payments found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters to inspect another part of the native ledger.</p></div>
38
- <div :else class="overflow-x-auto">
39
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
40
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
41
- <tr>
42
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Payment</th>
43
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Billing</th>
44
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Amount</th>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Method</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
47
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Created</th>
48
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs dark:text-neutral-300"><span class="sr-only">Actions</span></th>
49
- </tr>
50
- </thead>
51
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
52
- <template :for="record in records()" :key="record.id">
53
- <tr>
54
- <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm dark:text-white">#{{ record.id }}</p><p class="mt-1 max-w-44 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.referenceNumber || record.transactionId || 'No reference' }}</p></td>
55
- <td class="px-4 py-4"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.billingEmail || 'No billing email' }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.orderId ? 'Order #' + record.orderId : 'No linked order' }}</p></td>
56
- <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ money(record.amount, record.currency) }}</p><p :if="record.refundAmount > 0" class="mt-1 text-violet-600 text-xs dark:text-violet-300">Refunded {{ money(record.refundAmount, record.currency) }}</p><p :else-if="record.status === 'refunded' && !record.refundRecorded" class="mt-1 text-amber-600 text-xs dark:text-amber-300">Refund amount not recorded</p></td>
57
- <td class="px-4 py-4"><p class="capitalize text-gray-700 text-sm dark:text-neutral-200">{{ label(record.method) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.cardBrand }}<span :if="record.cardLastFour"> · {{ record.cardLastFour }}</span><span :if="!record.cardBrand && !record.cardLastFour">{{ record.paymentProvider || 'No processor details' }}</span></p></td>
58
- <td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs capitalize rounded-full ' + statusClass(record.status)">{{ label(record.status) }}</span></td>
59
- <td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ date(record.createdAt) }}</td>
60
- <td class="px-4 py-4 text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'View payment ' + record.id" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button></td>
61
- </tr>
62
- </template>
63
- </tbody>
64
- </table>
65
- </div>
66
- </div>
85
+ <Table
86
+ :columns="COLUMNS"
87
+ :data="rows()"
88
+ :loading="loading()"
89
+ :row-actions="ROW_ACTIONS"
90
+ action-label-key="id"
91
+ paginated
92
+ :page-size="25"
93
+ empty-title="No payments found"
94
+ empty-description="Adjust the filters to inspect another part of the native ledger."
95
+ @row-action="handleRowAction"
96
+ >
97
+ <template #cell="{ row }">
98
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.amountLabel"></p>
99
+ <p :if="row.refundLabel" class="mt-1 text-violet-600 text-xs dark:text-violet-300" x-text="row.refundLabel"></p>
100
+ <p :if="row.refundMissing" class="mt-1 text-amber-600 text-xs dark:text-amber-300">Refund amount not recorded</p>
101
+ </template>
102
+ </Table>