@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.
- package/ai/skills/stacks-browse/SKILL.md +3 -0
- package/ai/skills/stacks-browse/scripts/browse.ts +73 -14
- package/ai/skills/stacks-chat/SKILL.md +32 -0
- package/ai/skills/stacks-native/CATALOG.md +8 -8
- package/ide/vscode/package.json +1 -1
- package/package.json +4 -4
- package/resources/components/Dashboard/App/RequestsOverview.stx +75 -58
- package/resources/components/Dashboard/App/SourceInventory.stx +68 -59
- package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
- package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
- package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
- package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
- package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
- package/resources/components/Dashboard/Infrastructure/DnsDashboard.stx +46 -28
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
- package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
- package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +35 -61
- 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
|
-
|
|
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 '
|
|
102
|
+
return 'read'
|
|
111
103
|
if (method === 'ANY')
|
|
112
|
-
return '
|
|
113
|
-
return '
|
|
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
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
:
|
|
215
|
-
:
|
|
216
|
-
:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
<Button variant="danger" size="sm"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 '
|
|
23
|
+
return 'settled'
|
|
27
24
|
if (status === 'failed')
|
|
28
|
-
return '
|
|
25
|
+
return 'failed'
|
|
29
26
|
if (status === 'refunded' || status === 'partiallyRefunded')
|
|
30
|
-
return '
|
|
31
|
-
return '
|
|
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
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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>
|