@stacksjs/defaults 0.75.279 → 0.75.281
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-database/SKILL.md +1 -3
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +3 -3
- package/project/bun-version +1 -1
- 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
|
@@ -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
|
|
|
@@ -1,61 +1,83 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { CommerceCustomerRecord } from '../../../../app/Actions/Dashboard/Commerce/commerce-customer-records'
|
|
3
|
-
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
4
5
|
|
|
5
6
|
const records = useReactiveProp('records', [] as CommerceCustomerRecord[])
|
|
6
7
|
const loading = useReactiveProp('loading', false)
|
|
8
|
+
/** The parent's sort select: a change hands the order back from a header sort. */
|
|
9
|
+
const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
|
|
10
|
+
/** The parent's search and filters: a change returns to the first page. */
|
|
11
|
+
const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
|
|
7
12
|
const currency = useReactiveProp('currency', 'USD')
|
|
8
13
|
const emit = defineEmits()
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
15
|
+
/** Only the customer cell is slotted, for its initial. */
|
|
16
|
+
const COLUMNS = [
|
|
17
|
+
{ key: 'nameLabel', label: 'Customer', sortable: true, slotted: true },
|
|
18
|
+
{ key: 'emailLabel', label: 'Contact', sortable: true, subtitleKey: 'phoneLabel', class: 'text-gray-700 dark:text-neutral-200' },
|
|
19
|
+
// Money is integer minor units; the row carries the store currency so the
|
|
20
|
+
// column formats it through formatCurrency, not as a bare number.
|
|
21
|
+
{ key: 'totalSpent', label: 'Recorded spend', type: 'currency' as const, currencyKey: 'currency', sortable: true, class: 'font-medium' },
|
|
22
|
+
{ key: 'lastOrder', label: 'Last order', type: 'date' as const, sortable: true, emptyText: 'No order recorded', class: 'whitespace-nowrap' },
|
|
23
|
+
{
|
|
24
|
+
key: 'status',
|
|
25
|
+
label: 'Status',
|
|
26
|
+
type: 'badge' as const,
|
|
27
|
+
sortable: true,
|
|
28
|
+
badgeClasses: {
|
|
29
|
+
Active: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
|
|
30
|
+
Inactive: 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
]
|
|
13
34
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
const ROW_ACTIONS = [
|
|
36
|
+
{ action: 'view', label: 'View', icon: 'i-hugeicons-view' },
|
|
37
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
|
|
38
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const rows = derived(() => records().map(record => ({
|
|
42
|
+
...record,
|
|
43
|
+
currency: currency(),
|
|
44
|
+
nameLabel: record.name || 'Unnamed customer',
|
|
45
|
+
initial: (record.name || '?').slice(0, 1).toUpperCase(),
|
|
46
|
+
idLabel: `ID ${record.id || 'not recorded'}`,
|
|
47
|
+
emailLabel: record.email || 'No email recorded',
|
|
48
|
+
phoneLabel: record.phone || 'No phone recorded',
|
|
49
|
+
})))
|
|
27
50
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
return 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300'
|
|
31
|
-
if (record.status === 'Inactive')
|
|
32
|
-
return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
|
|
33
|
-
return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
|
|
51
|
+
function handleRowAction(payload: { action: string, row: CommerceCustomerRecord }): void {
|
|
52
|
+
emit(payload.action, payload.row)
|
|
34
53
|
}
|
|
35
54
|
</script>
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
</div>
|
|
56
|
+
{{--
|
|
57
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
58
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
59
|
+
--}}
|
|
60
|
+
<Table
|
|
61
|
+
:sort-reset-key="sortResetKey()"
|
|
62
|
+
:page-reset-key="pageResetKey()"
|
|
63
|
+
:columns="COLUMNS"
|
|
64
|
+
:data="rows()"
|
|
65
|
+
:loading="loading()"
|
|
66
|
+
:row-actions="ROW_ACTIONS"
|
|
67
|
+
action-label-key="nameLabel"
|
|
68
|
+
paginated
|
|
69
|
+
:page-size="15"
|
|
70
|
+
empty-title="No customers found"
|
|
71
|
+
empty-description="Adjust the filters or add a native Customer record."
|
|
72
|
+
@row-action="handleRowAction"
|
|
73
|
+
>
|
|
74
|
+
<template #cell="{ row }">
|
|
75
|
+
<div class="flex gap-3 items-center">
|
|
76
|
+
<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>
|
|
77
|
+
<div class="min-w-0">
|
|
78
|
+
<p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.nameLabel"></p>
|
|
79
|
+
<p class="mt-1 font-mono text-gray-400 text-xs dark:text-neutral-500" x-text="row.idLabel"></p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
</Table>
|
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
|
|
8
8
|
import { pushToast } from '../../../../functions/toasts'
|
|
9
9
|
import { minorToInput } from '@stacksjs/commerce/money'
|
|
10
|
-
import { pageControls } from '../../../../functions/dashboard-list'
|
|
11
10
|
|
|
12
11
|
interface GiftCardIndexResponse {
|
|
13
12
|
records: GiftCardRecord[]
|
|
@@ -49,8 +48,6 @@ const statusFilter = state('all')
|
|
|
49
48
|
const enabledFilter = state('all')
|
|
50
49
|
const deliveryFilter = state('all')
|
|
51
50
|
const sort = state('newest')
|
|
52
|
-
const page = state(1)
|
|
53
|
-
const pageSize = 20
|
|
54
51
|
const loading = state(true)
|
|
55
52
|
const loadError = state('')
|
|
56
53
|
const dialogOpen = state(false)
|
|
@@ -98,15 +95,6 @@ function filteredRecords(): GiftCardRecord[] {
|
|
|
98
95
|
})
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
function pageCount(): number {
|
|
102
|
-
return Math.max(1, Math.ceil(filteredRecords().length / pageSize))
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function visibleRecords(): GiftCardRecord[] {
|
|
106
|
-
const selectedPage = Math.min(page(), pageCount())
|
|
107
|
-
return filteredRecords().slice((selectedPage - 1) * pageSize, selectedPage * pageSize)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
98
|
function metricCards(): Array<{ label: string, value: string, detail: string }> {
|
|
111
99
|
const current = summary()
|
|
112
100
|
return [
|
|
@@ -140,14 +128,13 @@ function exportHref(): string {
|
|
|
140
128
|
return `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${csv}`)}`
|
|
141
129
|
}
|
|
142
130
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
sort()
|
|
149
|
-
|
|
150
|
-
})
|
|
131
|
+
/**
|
|
132
|
+
* What the table is showing: a change returns it to the first page, as this
|
|
133
|
+
* page's own pager did before the table took over paging.
|
|
134
|
+
*/
|
|
135
|
+
function viewKey(): string {
|
|
136
|
+
return JSON.stringify([search(), statusFilter(), enabledFilter(), deliveryFilter(), sort()])
|
|
137
|
+
}
|
|
151
138
|
|
|
152
139
|
async function loadGiftCards(): Promise<void> {
|
|
153
140
|
loading.set(true)
|
|
@@ -156,7 +143,6 @@ async function loadGiftCards(): Promise<void> {
|
|
|
156
143
|
const data = await dashboardApi<GiftCardIndexResponse>('/api/dashboard/commerce/gift-cards')
|
|
157
144
|
records.set(Array.isArray(data.records) ? data.records : [])
|
|
158
145
|
summary.set(data.summary ?? { ...emptySummary })
|
|
159
|
-
page.set(Math.min(page(), Math.max(1, Math.ceil((data.records?.length || 0) / pageSize))))
|
|
160
146
|
}
|
|
161
147
|
catch (error) {
|
|
162
148
|
loadError.set('Gift cards could not be loaded.')
|
|
@@ -258,8 +244,6 @@ async function deleteGiftCard(): Promise<void> {
|
|
|
258
244
|
}
|
|
259
245
|
}
|
|
260
246
|
|
|
261
|
-
const { previousPage, nextPage } = pageControls(page, pageCount)
|
|
262
|
-
|
|
263
247
|
onMount(() => {
|
|
264
248
|
void loadGiftCards()
|
|
265
249
|
})
|
|
@@ -295,14 +279,9 @@ onMount(() => {
|
|
|
295
279
|
|
|
296
280
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
297
281
|
<p class="text-gray-500 text-sm dark:text-neutral-400">{{ filteredRecords().length }} matching gift cards</p>
|
|
298
|
-
<div class="flex gap-2 items-center">
|
|
299
|
-
<Button variant="secondary" size="sm" :disabled="page() <= 1" @click="previousPage()">Previous</Button>
|
|
300
|
-
<span class="text-gray-500 text-sm dark:text-neutral-400">Page {{ Math.min(page(), pageCount()) }} of {{ pageCount() }}</span>
|
|
301
|
-
<Button variant="secondary" size="sm" :disabled="page() >= pageCount()" @click="nextPage()">Next</Button>
|
|
302
|
-
</div>
|
|
303
282
|
</div>
|
|
304
283
|
|
|
305
|
-
<CommerceGiftCardsTable :records="
|
|
284
|
+
<CommerceGiftCardsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @view="openDetails" @edit="openEdit" @delete="openDelete" />
|
|
306
285
|
</section>
|
|
307
286
|
</div>
|
|
308
287
|
|
|
@@ -1,94 +1,84 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import type { GiftCardRecord, GiftCardStatus } from '../../../../app/Actions/Dashboard/Commerce/gift-card-records'
|
|
3
5
|
import { formatCurrency } from '@stacksjs/commerce/money'
|
|
4
6
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function money(value: number, currency: string): string {
|
|
13
|
-
return formatCurrency(value, currency)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function date(value: string): string {
|
|
17
|
-
if (!value)
|
|
18
|
-
return 'No expiry'
|
|
19
|
-
const parsed = new Date(value)
|
|
20
|
-
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
|
|
21
|
-
}
|
|
7
|
+
const records = useReactiveProp('records', [] as GiftCardRecord[])
|
|
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', '')
|
|
13
|
+
const emit = defineEmits()
|
|
22
14
|
|
|
23
15
|
function statusLabel(status: GiftCardStatus): string {
|
|
24
16
|
return status.charAt(0) + status.slice(1).toLowerCase()
|
|
25
17
|
}
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The balance is a currency column keyed on each card's own currency; the
|
|
21
|
+
* initial balance beneath it is formatted here with the same helper, since
|
|
22
|
+
* a subtitle is text.
|
|
23
|
+
*/
|
|
24
|
+
const COLUMNS = [
|
|
25
|
+
{ key: 'code', label: 'Gift card', sortable: true, subtitleKey: 'currency', class: 'font-medium text-gray-900 dark:text-white' },
|
|
26
|
+
{ key: 'recipientLabel', label: 'Recipient', sortable: true, subtitleKey: 'recipientEmailLabel', class: 'text-gray-900 dark:text-white' },
|
|
27
|
+
{ key: 'currentBalance', label: 'Balance', type: 'currency' as const, currencyKey: 'currency', sortable: true, subtitleKey: 'initialLabel', class: 'font-medium text-gray-900 dark:text-white' },
|
|
28
|
+
{
|
|
29
|
+
key: 'status',
|
|
30
|
+
label: 'Status',
|
|
31
|
+
type: 'badge' as const,
|
|
32
|
+
sortable: true,
|
|
33
|
+
badgeLabelKey: 'statusLabel',
|
|
34
|
+
subtitleKey: 'enabledLabel',
|
|
35
|
+
badgeClasses: {
|
|
36
|
+
ACTIVE: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
|
|
37
|
+
USED: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
|
|
38
|
+
EXPIRED: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{ key: 'deliveryLabel', label: 'Delivery', sortable: true, subtitleKey: 'reloadLabel', class: 'text-gray-700 dark:text-neutral-200' },
|
|
42
|
+
{ key: 'expiryDate', label: 'Expiry', type: 'date' as const, sortable: true, emptyText: 'No expiry', class: 'whitespace-nowrap' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const ROW_ACTIONS = [
|
|
46
|
+
{ action: 'view', label: 'View', icon: 'i-hugeicons-view' },
|
|
47
|
+
{ action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-02' },
|
|
48
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const rows = derived(() => records().map(record => ({
|
|
52
|
+
...record,
|
|
53
|
+
recipientLabel: record.recipientName || 'Unassigned',
|
|
54
|
+
recipientEmailLabel: record.recipientEmail || 'No recipient email',
|
|
55
|
+
initialLabel: `of ${formatCurrency(record.initialBalance, record.currency)}`,
|
|
56
|
+
statusLabel: statusLabel(record.status),
|
|
57
|
+
enabledLabel: record.isActive ? 'Enabled' : 'Disabled',
|
|
58
|
+
deliveryLabel: record.isDigital ? 'Digital' : 'Physical',
|
|
59
|
+
reloadLabel: record.isReloadable ? 'Reloadable' : 'Single use',
|
|
60
|
+
})))
|
|
61
|
+
|
|
62
|
+
function handleRowAction(payload: { action: string, row: GiftCardRecord }): void {
|
|
63
|
+
emit(payload.action, payload.row)
|
|
35
64
|
}
|
|
36
65
|
</script>
|
|
37
66
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</tr>
|
|
57
|
-
</thead>
|
|
58
|
-
<tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
|
|
59
|
-
<template :for="record in records" :key="record.id">
|
|
60
|
-
<tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-900/40">
|
|
61
|
-
<td class="px-4 py-3">
|
|
62
|
-
<Button variant="ghost" size="xs" @click="emit('view', record)">{{ record.code }}</Button>
|
|
63
|
-
<p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.currency }}</p>
|
|
64
|
-
</td>
|
|
65
|
-
<td class="px-4 py-3">
|
|
66
|
-
<p class="text-gray-900 text-sm dark:text-white">{{ record.recipientName || 'Unassigned' }}</p>
|
|
67
|
-
<p class="mt-1 max-w-52 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.recipientEmail || 'No recipient email' }}</p>
|
|
68
|
-
</td>
|
|
69
|
-
<td class="px-4 py-3">
|
|
70
|
-
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ money(record.currentBalance, record.currency) }}</p>
|
|
71
|
-
<p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">of {{ money(record.initialBalance, record.currency) }}</p>
|
|
72
|
-
</td>
|
|
73
|
-
<td class="px-4 py-3">
|
|
74
|
-
<span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record.status)">{{ statusLabel(record.status) }}</span>
|
|
75
|
-
<p :class="'mt-1 text-xs ' + (record.isActive ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-neutral-500')">{{ record.isActive ? 'Enabled' : 'Disabled' }}</p>
|
|
76
|
-
</td>
|
|
77
|
-
<td class="px-4 py-3">
|
|
78
|
-
<p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.isDigital ? 'Digital' : 'Physical' }}</p>
|
|
79
|
-
<p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.isReloadable ? 'Reloadable' : 'Single use' }}</p>
|
|
80
|
-
</td>
|
|
81
|
-
<td class="px-4 py-3 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ date(record.expiryDate) }}</td>
|
|
82
|
-
<td class="px-4 py-3">
|
|
83
|
-
<div class="flex gap-1 justify-end">
|
|
84
|
-
<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>
|
|
85
|
-
<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-02"></span></Button>
|
|
86
|
-
<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-delete-02"></span></Button>
|
|
87
|
-
</div>
|
|
88
|
-
</td>
|
|
89
|
-
</tr>
|
|
90
|
-
</template>
|
|
91
|
-
</tbody>
|
|
92
|
-
</table>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
67
|
+
{{--
|
|
68
|
+
No `searchable` or `filterable`: the parent dashboard owns the search box
|
|
69
|
+
and the filter selects. Table pages the whole filtered list the parent passes.
|
|
70
|
+
--}}
|
|
71
|
+
<Table
|
|
72
|
+
:sort-reset-key="sortResetKey()"
|
|
73
|
+
:page-reset-key="pageResetKey()"
|
|
74
|
+
:columns="COLUMNS"
|
|
75
|
+
:data="rows()"
|
|
76
|
+
:loading="loading()"
|
|
77
|
+
:row-actions="ROW_ACTIONS"
|
|
78
|
+
action-label-key="code"
|
|
79
|
+
paginated
|
|
80
|
+
:page-size="20"
|
|
81
|
+
empty-title="No gift cards found"
|
|
82
|
+
empty-description="Adjust the filters or add a native GiftCard record."
|
|
83
|
+
@row-action="handleRowAction"
|
|
84
|
+
/>
|
|
@@ -233,7 +233,7 @@ onMount(() => {
|
|
|
233
233
|
</label>
|
|
234
234
|
</div>
|
|
235
235
|
|
|
236
|
-
<CommerceManufacturersTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
|
|
236
|
+
<CommerceManufacturersTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
|
|
237
237
|
</section>
|
|
238
238
|
</div>
|
|
239
239
|
|