@stacksjs/defaults 0.75.279 → 0.75.280
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/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
- package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
- package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
- package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
- package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
- package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
- package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
- package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
- package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
- package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
- package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
- package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
- package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
- package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
- package/resources/components/Dashboard/UI/Table.stx +41 -2
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.280",
|
|
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.
|
|
5
|
+
"version": "0.75.280",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iconify-json/f7": "^1.2.2",
|
|
47
47
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
48
|
-
"@stacksjs/mobile": "0.75.
|
|
48
|
+
"@stacksjs/mobile": "0.75.280",
|
|
49
49
|
"@stacksjs/sanitizer": "0.2.669",
|
|
50
50
|
"@stacksjs/stx": "0.2.671",
|
|
51
51
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { AnalyticsRange, CampaignAnalyticsResponse } from '../../../../functions/analytics'
|
|
3
5
|
import { fetchCampaignAnalytics } from '../../../../functions/analytics'
|
|
@@ -34,6 +36,23 @@ function campaigns(): CampaignAnalyticsResponse['campaigns'] {
|
|
|
34
36
|
return analytics()?.campaigns || []
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Every campaign in the range, so this is the one table on the page that
|
|
41
|
+
* grows without bound: it pages. Counts the campaign did not record read
|
|
42
|
+
* "Not recorded" rather than 0, and spend is formatted in each campaign's own
|
|
43
|
+
* currency, never summed across them.
|
|
44
|
+
*/
|
|
45
|
+
const CAMPAIGN_COLUMNS = [
|
|
46
|
+
{ key: 'name', label: 'Campaign', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
|
|
47
|
+
{ key: 'type', label: 'Channel', sortable: true },
|
|
48
|
+
{ key: 'status', label: 'Status', sortable: true },
|
|
49
|
+
{ key: 'sentCount', label: 'Sent', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
|
|
50
|
+
{ key: 'opens', label: 'Opens', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
|
|
51
|
+
{ key: 'clicks', label: 'Clicks', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
|
|
52
|
+
{ key: 'conversions', label: 'Conversions', type: 'number' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
|
|
53
|
+
{ key: 'spent', label: 'Spent', type: 'currency' as const, currencyKey: 'currency', sortable: true, align: 'right' as const, emptyText: 'Not recorded' },
|
|
54
|
+
]
|
|
55
|
+
|
|
37
56
|
function channels(): CampaignAnalyticsResponse['channels'] {
|
|
38
57
|
return analytics()?.channels || []
|
|
39
58
|
}
|
|
@@ -141,36 +160,15 @@ onMount(() => {
|
|
|
141
160
|
<h2 class="font-semibold text-gray-900 dark:text-white">Campaign performance</h2>
|
|
142
161
|
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Engagement counts are derived from each campaign's recorded sent count and rates.</p>
|
|
143
162
|
</div>
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Clicks</th>
|
|
154
|
-
<th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Conversions</th>
|
|
155
|
-
<th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Spent</th>
|
|
156
|
-
</tr>
|
|
157
|
-
</thead>
|
|
158
|
-
<tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
159
|
-
<template :for="campaign in campaigns()">
|
|
160
|
-
<tr>
|
|
161
|
-
<td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ campaign.name }}</td>
|
|
162
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ campaign.type }}</td>
|
|
163
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ campaign.status }}</td>
|
|
164
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.sentCount) }}</td>
|
|
165
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.opens) }}</td>
|
|
166
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.clicks) }}</td>
|
|
167
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatNumber(campaign.conversions) }}</td>
|
|
168
|
-
<td class="px-4 py-3 text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatMoney(campaign.spent, campaign.currency) }}</td>
|
|
169
|
-
</tr>
|
|
170
|
-
</template>
|
|
171
|
-
</tbody>
|
|
172
|
-
</table>
|
|
173
|
-
</div>
|
|
163
|
+
<Table
|
|
164
|
+
:columns="CAMPAIGN_COLUMNS"
|
|
165
|
+
:data="campaigns()"
|
|
166
|
+
:loading="isLoading()"
|
|
167
|
+
paginated
|
|
168
|
+
:page-size="25"
|
|
169
|
+
empty-title="No campaigns in this period"
|
|
170
|
+
empty-description="Create and send campaigns from Marketing to populate this table."
|
|
171
|
+
/>
|
|
174
172
|
</section>
|
|
175
173
|
|
|
176
174
|
<section class="grid gap-6 grid-cols-1 xl:grid-cols-2">
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
} from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
|
|
6
6
|
import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
|
|
7
7
|
import { pushToast } from '../../../../functions/toasts'
|
|
8
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
9
8
|
|
|
10
9
|
interface CategoryIndexResponse {
|
|
11
10
|
records: CommerceCategoryRecord[]
|
|
@@ -29,8 +28,6 @@ const search = state('')
|
|
|
29
28
|
const activeFilter = state('all')
|
|
30
29
|
const hierarchyFilter = state('all')
|
|
31
30
|
const sort = state('order')
|
|
32
|
-
const page = state(1)
|
|
33
|
-
const pageSize = 20
|
|
34
31
|
const loading = state(true)
|
|
35
32
|
const loadError = state('')
|
|
36
33
|
const dialogOpen = state(false)
|
|
@@ -72,15 +69,6 @@ function filteredRecords(): CommerceCategoryRecord[] {
|
|
|
72
69
|
})
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
function pageCount(): number {
|
|
76
|
-
return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function visibleRecords(): CommerceCategoryRecord[] {
|
|
80
|
-
const selectedPage = Math.min(page(), pageCount())
|
|
81
|
-
return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
72
|
function matchingLabel(): string {
|
|
85
73
|
const count = filteredRecords().length
|
|
86
74
|
return `${count} matching ${count === 1 ? 'category' : 'categories'}`
|
|
@@ -114,13 +102,13 @@ function exportHref(): string {
|
|
|
114
102
|
return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
|
|
115
103
|
}
|
|
116
104
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
105
|
+
/**
|
|
106
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
107
|
+
* page's own pager did before the table took over paging.
|
|
108
|
+
*/
|
|
109
|
+
function viewKey(): string {
|
|
110
|
+
return JSON.stringify([search(), activeFilter(), hierarchyFilter(), sort()])
|
|
111
|
+
}
|
|
124
112
|
|
|
125
113
|
async function loadCategories(): Promise<void> {
|
|
126
114
|
loading.set(true)
|
|
@@ -129,7 +117,6 @@ async function loadCategories(): Promise<void> {
|
|
|
129
117
|
const data = await dashboardApi<CategoryIndexResponse>('/api/dashboard/commerce/categories')
|
|
130
118
|
records.set(Array.isArray(data.records) ? data.records : [])
|
|
131
119
|
summary.set(data.summary ?? { ...emptySummary })
|
|
132
|
-
page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
|
|
133
120
|
}
|
|
134
121
|
catch (error) {
|
|
135
122
|
loadError.set('Product categories could not be loaded.')
|
|
@@ -231,8 +218,6 @@ async function deleteCategory(): Promise<void> {
|
|
|
231
218
|
}
|
|
232
219
|
}
|
|
233
220
|
|
|
234
|
-
const { previousPage, nextPage } = pageControls(page, pageCount)
|
|
235
|
-
|
|
236
221
|
onMount(() => {
|
|
237
222
|
void loadCategories()
|
|
238
223
|
})
|
|
@@ -267,14 +252,9 @@ onMount(() => {
|
|
|
267
252
|
|
|
268
253
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
269
254
|
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ matchingLabel() }}</p>
|
|
270
|
-
<div class="flex gap-2 items-center">
|
|
271
|
-
<Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
|
|
272
|
-
<span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
|
|
273
|
-
<Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
|
|
274
|
-
</div>
|
|
275
255
|
</div>
|
|
276
256
|
|
|
277
|
-
<CommerceCategoriesTable :records="
|
|
257
|
+
<CommerceCategoriesTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :all-records="records()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
|
|
278
258
|
</section>
|
|
279
259
|
</div>
|
|
280
260
|
|
|
@@ -1,43 +1,86 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { CommerceCategoryRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-category-records'
|
|
3
5
|
|
|
4
6
|
const records = useReactiveProp('records', [] as CommerceCategoryRecord[])
|
|
5
7
|
const allRecords = useReactiveProp('allRecords', [] as CommerceCategoryRecord[])
|
|
6
8
|
const loading = useReactiveProp('loading', false)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
10
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
11
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
12
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
13
|
+
const emit = defineEmits()
|
|
12
14
|
|
|
13
15
|
function parentName(record: CommerceCategoryRecord): string {
|
|
14
16
|
if (!record.parentCategoryId)
|
|
15
17
|
return 'Root category'
|
|
16
18
|
return allRecords().find(parent => parent.id === record.parentCategoryId)?.name || 'Unknown parent'
|
|
17
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Only the category cell is slotted, for its initial. The parent comes from
|
|
23
|
+
* `allRecords`, which can hold categories the current filter hides, so it is
|
|
24
|
+
* resolved into the row rather than looked up per cell.
|
|
25
|
+
*/
|
|
26
|
+
const COLUMNS = [
|
|
27
|
+
{ key: 'name', label: 'Category', sortable: true, slotted: true },
|
|
28
|
+
{ key: 'parentLabel', label: 'Hierarchy', sortable: true, subtitleKey: 'slug', class: 'text-gray-700 dark:text-neutral-200' },
|
|
29
|
+
{ key: 'displayOrder', label: 'Display order', type: 'number' as const, sortable: true, class: 'font-mono' },
|
|
30
|
+
{
|
|
31
|
+
key: 'availability',
|
|
32
|
+
label: 'Availability',
|
|
33
|
+
type: 'badge' as const,
|
|
34
|
+
sortable: true,
|
|
35
|
+
badgeClasses: { active: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300' },
|
|
36
|
+
badgeLabels: { active: 'Active', inactive: 'Inactive' },
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const ROW_ACTIONS = [
|
|
41
|
+
{ action: 'view', label: 'View', icon: 'i-hugeicons-view' },
|
|
42
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
|
|
43
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const rows = derived(() => records().map(record => ({
|
|
47
|
+
...record,
|
|
48
|
+
initial: record.name.slice(0, 1).toUpperCase(),
|
|
49
|
+
summary: record.description || record.slug,
|
|
50
|
+
parentLabel: parentName(record),
|
|
51
|
+
availability: record.isActive ? 'active' : 'inactive',
|
|
52
|
+
})))
|
|
53
|
+
|
|
54
|
+
function handleRowAction(payload: { action: string, row: CommerceCategoryRecord }): void {
|
|
55
|
+
emit(payload.action, payload.row)
|
|
56
|
+
}
|
|
18
57
|
</script>
|
|
19
58
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
{{--
|
|
60
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
61
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
62
|
+
--}}
|
|
63
|
+
<Table
|
|
64
|
+
:sort-reset-key="sortResetKey()"
|
|
65
|
+
:page-reset-key="pageResetKey()"
|
|
66
|
+
:columns="COLUMNS"
|
|
67
|
+
:data="rows()"
|
|
68
|
+
:loading="loading()"
|
|
69
|
+
:row-actions="ROW_ACTIONS"
|
|
70
|
+
action-label-key="name"
|
|
71
|
+
paginated
|
|
72
|
+
:page-size="20"
|
|
73
|
+
empty-title="No product categories found"
|
|
74
|
+
empty-description="Adjust the filters or add a native Category record."
|
|
75
|
+
@row-action="handleRowAction"
|
|
76
|
+
>
|
|
77
|
+
<template #cell="{ row }">
|
|
78
|
+
<div class="flex gap-3 items-center">
|
|
79
|
+
<span class="flex items-center justify-center shrink-0 h-9 w-9 font-semibold text-blue-700 text-sm dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-md" x-text="row.initial"></span>
|
|
80
|
+
<div class="min-w-0">
|
|
81
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.name"></p>
|
|
82
|
+
<p class="mt-1 max-w-96 text-gray-400 text-xs truncate dark:text-neutral-500" x-text="row.summary"></p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
</Table>
|
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
|
|
9
9
|
import { pushToast } from '../../../../functions/toasts'
|
|
10
10
|
import { minorToInput } from '@stacksjs/commerce/money'
|
|
11
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
12
11
|
|
|
13
12
|
interface CouponIndexResponse {
|
|
14
13
|
records: CouponRecord[]
|
|
@@ -38,8 +37,6 @@ const statusFilter = state('all')
|
|
|
38
37
|
const enabledFilter = state('all')
|
|
39
38
|
const typeFilter = state('all')
|
|
40
39
|
const sort = state('newest')
|
|
41
|
-
const page = state(1)
|
|
42
|
-
const pageSize = 20
|
|
43
40
|
const loading = state(true)
|
|
44
41
|
const loadError = state('')
|
|
45
42
|
const dialogOpen = state(false)
|
|
@@ -80,15 +77,6 @@ function filteredRecords(): CouponRecord[] {
|
|
|
80
77
|
})
|
|
81
78
|
}
|
|
82
79
|
|
|
83
|
-
function pageCount(): number {
|
|
84
|
-
return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function visibleRecords(): CouponRecord[] {
|
|
88
|
-
const selectedPage = Math.min(page(), pageCount())
|
|
89
|
-
return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
80
|
function metricCards(): Array<{ label: string, value: string, detail: string }> {
|
|
93
81
|
const current = summary()
|
|
94
82
|
return [
|
|
@@ -121,14 +109,13 @@ function exportHref(): string {
|
|
|
121
109
|
return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
|
|
122
110
|
}
|
|
123
111
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
sort()
|
|
130
|
-
|
|
131
|
-
})
|
|
112
|
+
/**
|
|
113
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
114
|
+
* page's own pager did before the table took over paging.
|
|
115
|
+
*/
|
|
116
|
+
function viewKey(): string {
|
|
117
|
+
return JSON.stringify([search(), statusFilter(), enabledFilter(), typeFilter(), sort()])
|
|
118
|
+
}
|
|
132
119
|
|
|
133
120
|
async function loadCoupons(): Promise<void> {
|
|
134
121
|
loading.set(true)
|
|
@@ -137,7 +124,6 @@ async function loadCoupons(): Promise<void> {
|
|
|
137
124
|
const data = await dashboardApi<CouponIndexResponse>('/api/dashboard/commerce/coupons')
|
|
138
125
|
records.set(Array.isArray(data.records) ? data.records : [])
|
|
139
126
|
summary.set(data.summary ?? { ...emptySummary })
|
|
140
|
-
page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
|
|
141
127
|
}
|
|
142
128
|
catch (error) {
|
|
143
129
|
loadError.set('Coupons could not be loaded.')
|
|
@@ -242,8 +228,6 @@ async function deleteCoupon(): Promise<void> {
|
|
|
242
228
|
}
|
|
243
229
|
}
|
|
244
230
|
|
|
245
|
-
const { previousPage, nextPage } = pageControls(page, pageCount)
|
|
246
|
-
|
|
247
231
|
onMount(() => {
|
|
248
232
|
void loadCoupons()
|
|
249
233
|
})
|
|
@@ -279,14 +263,9 @@ onMount(() => {
|
|
|
279
263
|
|
|
280
264
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
281
265
|
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ filteredRecords().length }} matching coupons</p>
|
|
282
|
-
<div class="flex gap-2 items-center">
|
|
283
|
-
<Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
|
|
284
|
-
<span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
|
|
285
|
-
<Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
|
|
286
|
-
</div>
|
|
287
266
|
</div>
|
|
288
267
|
|
|
289
|
-
<CommerceCouponsTable :records="
|
|
268
|
+
<CommerceCouponsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
|
|
290
269
|
</section>
|
|
291
270
|
</div>
|
|
292
271
|
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { CouponRecord } from '../../../../app/Actions/Dashboard/Commerce/coupon-records'
|
|
3
5
|
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
4
6
|
|
|
5
7
|
const records = useReactiveProp('records', [] as CouponRecord[])
|
|
6
8
|
const loading = useReactiveProp('loading', false)
|
|
9
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
10
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
11
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
12
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
7
13
|
const currency = useReactiveProp('currency', 'USD')
|
|
8
14
|
const emit = defineEmits()
|
|
9
15
|
|
|
10
|
-
function statusClass(status: string): string {
|
|
11
|
-
if (status === 'Active')
|
|
12
|
-
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300'
|
|
13
|
-
if (status === 'Scheduled')
|
|
14
|
-
return 'bg-blue-100 text-blue-800 dark:bg-blue-950/50 dark:text-blue-300'
|
|
15
|
-
return 'bg-gray-100 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300'
|
|
16
|
-
}
|
|
17
|
-
|
|
18
16
|
// A percentage is a rate, not money; a fixed amount is integer minor units.
|
|
19
17
|
function discount(record: CouponRecord): string {
|
|
20
18
|
return record.discountType === 'percentage'
|
|
@@ -22,47 +20,69 @@ function discount(record: CouponRecord): string {
|
|
|
22
20
|
: formatCurrency(record.discountValue, currency())
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
:
|
|
23
|
+
const COLUMNS = [
|
|
24
|
+
{ key: 'codeLabel', label: 'Coupon', sortable: true, subtitleKey: 'descriptionLabel', class: 'font-medium text-gray-900 dark:text-white' },
|
|
25
|
+
{ key: 'discountLabel', label: 'Discount', subtitleKey: 'discountKind', searchable: false, class: 'font-semibold text-gray-700 whitespace-nowrap dark:text-neutral-200' },
|
|
26
|
+
{
|
|
27
|
+
key: 'status',
|
|
28
|
+
label: 'Status',
|
|
29
|
+
type: 'badge' as const,
|
|
30
|
+
sortable: true,
|
|
31
|
+
badgeClasses: {
|
|
32
|
+
Active: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300',
|
|
33
|
+
Scheduled: 'bg-blue-100 text-blue-800 dark:bg-blue-950/50 dark:text-blue-300',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: 'availability',
|
|
38
|
+
label: 'Availability',
|
|
39
|
+
type: 'badge' as const,
|
|
40
|
+
sortable: true,
|
|
41
|
+
badgeClasses: { enabled: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40' },
|
|
42
|
+
badgeLabels: { enabled: 'Enabled', disabled: 'Disabled' },
|
|
43
|
+
},
|
|
44
|
+
{ key: 'usageLabel', label: 'Usage', align: 'right' as const, searchable: false, class: 'font-mono whitespace-nowrap' },
|
|
45
|
+
// Calendar dates: parseDashboardDate reads them as that day in every zone.
|
|
46
|
+
{ key: 'startDate', label: 'Starts', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'whitespace-nowrap' },
|
|
47
|
+
{ key: 'endDate', label: 'Ends', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'whitespace-nowrap' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const ROW_ACTIONS = [
|
|
51
|
+
{ action: 'view', label: 'View', icon: 'i-hugeicons-view' },
|
|
52
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
|
|
53
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const rows = derived(() => records().map(record => ({
|
|
57
|
+
...record,
|
|
58
|
+
codeLabel: record.code || 'No code',
|
|
59
|
+
descriptionLabel: record.description || 'No description recorded',
|
|
60
|
+
discountLabel: discount(record),
|
|
61
|
+
discountKind: record.discountType === 'percentage' ? 'Percentage' : 'Fixed amount',
|
|
62
|
+
availability: record.isActive ? 'enabled' : 'disabled',
|
|
63
|
+
usageLabel: `${record.usageCount.toLocaleString()} / ${record.usageLimit ? record.usageLimit.toLocaleString() : 'No limit'}`,
|
|
64
|
+
})))
|
|
65
|
+
|
|
66
|
+
function handleRowAction(payload: { action: string, row: CouponRecord }): void {
|
|
67
|
+
emit(payload.action, payload.row)
|
|
32
68
|
}
|
|
33
69
|
</script>
|
|
34
70
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<td class="px-4 py-4 text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200"><span class="font-semibold">{{ discount(record) }}</span><p class="mt-1 text-gray-400 text-xs">{{ record.discountType === 'percentage' ? 'Percentage' : 'Fixed amount' }}</p></td>
|
|
54
|
-
<td class="px-4 py-4 whitespace-nowrap"><span :class="'inline-flex px-2 py-1 font-semibold text-xs rounded-full ' + statusClass(record.status)">{{ record.status }}</span></td>
|
|
55
|
-
<td class="px-4 py-4 text-sm whitespace-nowrap"><span :class="record.isActive ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-neutral-400'">{{ record.isActive ? 'Enabled' : 'Disabled' }}</span></td>
|
|
56
|
-
<td class="px-4 py-4 font-mono text-gray-700 text-right text-sm whitespace-nowrap dark:text-neutral-200">{{ record.usageCount.toLocaleString() }} / {{ record.usageLimit ? record.usageLimit.toLocaleString() : 'No limit' }}</td>
|
|
57
|
-
<td class="px-4 py-4 min-w-44 text-gray-500 text-sm dark:text-neutral-400"><p>{{ date(record.startDate) }}</p><p class="mt-1 text-xs">to {{ date(record.endDate) }}</p></td>
|
|
58
|
-
<td class="px-4 py-3 text-right whitespace-nowrap">
|
|
59
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'View ' + record.code" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button>
|
|
60
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + record.code" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button>
|
|
61
|
-
<Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.code" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span></Button>
|
|
62
|
-
</td>
|
|
63
|
-
</tr>
|
|
64
|
-
</template>
|
|
65
|
-
</tbody>
|
|
66
|
-
</table>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
71
|
+
{{--
|
|
72
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
73
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
74
|
+
--}}
|
|
75
|
+
<Table
|
|
76
|
+
:sort-reset-key="sortResetKey()"
|
|
77
|
+
:page-reset-key="pageResetKey()"
|
|
78
|
+
:columns="COLUMNS"
|
|
79
|
+
:data="rows()"
|
|
80
|
+
:loading="loading()"
|
|
81
|
+
:row-actions="ROW_ACTIONS"
|
|
82
|
+
action-label-key="codeLabel"
|
|
83
|
+
paginated
|
|
84
|
+
:page-size="20"
|
|
85
|
+
empty-title="No coupons found"
|
|
86
|
+
empty-description="Add a coupon or adjust the current filters."
|
|
87
|
+
@row-action="handleRowAction"
|
|
88
|
+
/>
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
|
|
7
7
|
import { pushToast } from '../../../../functions/toasts'
|
|
8
8
|
import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
|
|
9
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
10
9
|
|
|
11
10
|
interface CustomerIndexResponse {
|
|
12
11
|
records: CommerceCustomerRecord[]
|
|
@@ -37,8 +36,6 @@ const currency = state('USD')
|
|
|
37
36
|
const search = state('')
|
|
38
37
|
const statusFilter = state('all')
|
|
39
38
|
const sort = state('name')
|
|
40
|
-
const page = state(1)
|
|
41
|
-
const pageSize = 15
|
|
42
39
|
const loading = state(true)
|
|
43
40
|
const loadError = state('')
|
|
44
41
|
const dialogOpen = state(false)
|
|
@@ -85,15 +82,6 @@ function filteredRecords(): CommerceCustomerRecord[] {
|
|
|
85
82
|
})
|
|
86
83
|
}
|
|
87
84
|
|
|
88
|
-
function pageCount(): number {
|
|
89
|
-
return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function visibleRecords(): CommerceCustomerRecord[] {
|
|
93
|
-
const selectedPage = Math.min(page(), pageCount())
|
|
94
|
-
return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
85
|
function matchingLabel(): string {
|
|
98
86
|
const count = filteredRecords().length
|
|
99
87
|
return `${count} matching ${count === 1 ? 'customer' : 'customers'}`
|
|
@@ -127,12 +115,13 @@ function exportHref(): string {
|
|
|
127
115
|
return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
|
|
128
116
|
}
|
|
129
117
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
118
|
+
/**
|
|
119
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
120
|
+
* page's own pager did before the table took over paging.
|
|
121
|
+
*/
|
|
122
|
+
function viewKey(): string {
|
|
123
|
+
return JSON.stringify([search(), statusFilter(), sort()])
|
|
124
|
+
}
|
|
136
125
|
|
|
137
126
|
async function loadCustomers(): Promise<void> {
|
|
138
127
|
loading.set(true)
|
|
@@ -142,7 +131,6 @@ async function loadCustomers(): Promise<void> {
|
|
|
142
131
|
records.set(Array.isArray(data.records) ? data.records : [])
|
|
143
132
|
summary.set(data.summary ?? { ...emptySummary })
|
|
144
133
|
currency.set(data.currency || 'USD')
|
|
145
|
-
page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
|
|
146
134
|
}
|
|
147
135
|
catch (error) {
|
|
148
136
|
loadError.set('Customer records could not be loaded.')
|
|
@@ -244,8 +232,6 @@ async function deleteCustomer(): Promise<void> {
|
|
|
244
232
|
}
|
|
245
233
|
}
|
|
246
234
|
|
|
247
|
-
const { previousPage, nextPage } = pageControls(page, pageCount)
|
|
248
|
-
|
|
249
235
|
onMount(() => {
|
|
250
236
|
void loadCustomers()
|
|
251
237
|
})
|
|
@@ -277,14 +263,9 @@ onMount(() => {
|
|
|
277
263
|
|
|
278
264
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
279
265
|
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ matchingLabel() }}</p>
|
|
280
|
-
<div class="flex gap-2 items-center">
|
|
281
|
-
<Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
|
|
282
|
-
<span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
|
|
283
|
-
<Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
|
|
284
|
-
</div>
|
|
285
266
|
</div>
|
|
286
267
|
|
|
287
|
-
<CommerceCustomersTable :records="
|
|
268
|
+
<CommerceCustomersTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" :currency="currency()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
|
|
288
269
|
</section>
|
|
289
270
|
</div>
|
|
290
271
|
|