@stacksjs/defaults 0.75.231 → 0.75.236

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  2. package/ai/skills/stacks-database/SKILL.md +1 -1
  3. package/ai/skills/stacks-deploy/SKILL.md +3 -2
  4. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  5. package/ai/skills/stacks-native/CATALOG.md +10 -10
  6. package/ai/skills/stacks-realtime/SKILL.md +4 -2
  7. package/ai/skills/stacks-storage/SKILL.md +43 -0
  8. package/ai/skills/stacks-testing/SKILL.md +38 -10
  9. package/app/Actions/Monitoring/error-provider.ts +129 -12
  10. package/app/Models/Error.ts +21 -0
  11. package/functions/dashboard-form.test.ts +41 -0
  12. package/functions/dashboard-form.ts +30 -0
  13. package/functions/dashboard-list.test.ts +228 -0
  14. package/functions/dashboard-list.ts +257 -0
  15. package/ide/vscode/package.json +1 -1
  16. package/package.json +2 -2
  17. package/project/storage/framework/types/browser-composables.d.ts +56 -0
  18. package/resources/components/Dashboard/Auth/AuthCard.stx +29 -0
  19. package/resources/components/Dashboard/Auth/ForgotPassword.stx +65 -79
  20. package/resources/components/Dashboard/Auth/Login.stx +73 -82
  21. package/resources/components/Dashboard/Auth/Register.stx +121 -135
  22. package/resources/components/Dashboard/Auth/ResetPassword.stx +88 -102
  23. package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +11 -15
  24. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +10 -27
  25. package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +2 -4
  26. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +2 -4
  27. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +10 -27
  28. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +2 -4
  29. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +10 -27
  30. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +2 -4
  31. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +10 -27
  32. package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +2 -4
  33. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +13 -24
  34. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +2 -4
  35. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +10 -27
  36. package/resources/components/Dashboard/Commerce/CommerceOverviewDashboard.stx +17 -21
  37. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +7 -19
  38. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +4 -16
  39. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +2 -4
  40. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +10 -27
  41. package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +2 -4
  42. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +7 -18
  43. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +2 -4
  44. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +13 -24
  45. package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +2 -4
  46. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +13 -24
  47. package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +2 -4
  48. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +7 -18
  49. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +52 -105
  50. package/resources/components/Dashboard/Commerce/Delivery/DeliveryOverviewDashboard.stx +12 -16
  51. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +53 -105
  52. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +7 -13
  53. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +53 -105
  54. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +7 -13
  55. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +51 -103
  56. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +52 -104
  57. package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +2 -4
  58. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +10 -27
  59. package/resources/components/Dashboard/Commerce/PrintLogsDashboard.stx +7 -19
  60. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +12 -16
  61. package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +2 -4
  62. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +12 -16
  63. package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +2 -4
  64. package/resources/components/Dashboard/Deployments/DeploymentList.stx +17 -21
  65. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +8 -19
  66. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +8 -19
  67. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +11 -22
  68. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +8 -19
  69. package/resources/components/Dashboard/Queue/QueueDashboard.stx +12 -16
  70. package/resources/components/Dashboard/Realtime/RealtimeDashboard.stx +6 -10
  71. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +6 -10
  72. package/resources/components/Dashboard/UI/MetricCards.stx +32 -0
  73. package/resources/components/Dashboard/UI/PageHeader.stx +25 -66
  74. package/resources/components/Dashboard/UI/Table.stx +79 -6
  75. package/resources/plugins/preloader.ts +9 -1
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import { dashboardApi } from '../../../../../functions/dashboard-api'
3
+ import { useRecordsDashboard } from '../../../../../functions/dashboard-list'
3
4
  import { pushToast } from '../../../../../functions/toasts'
4
5
 
5
6
  type CourierStatus = 'active' | 'on_delivery' | 'on_break'
@@ -34,21 +35,48 @@ interface CourierPayload {
34
35
  userId: number | null
35
36
  }
36
37
 
37
- const records = state<CourierRecord[]>([])
38
+ function matchesQuery(records: CourierRecord[], query: string): CourierRecord[] {
39
+ return records.filter(record => [
40
+ record.name,
41
+ record.phone,
42
+ record.vehicle_number,
43
+ record.license,
44
+ record.status,
45
+ record.user?.name,
46
+ record.user?.email,
47
+ ].some(value => String(value || '').toLowerCase().includes(query)))
48
+ }
49
+
50
+ const {
51
+ records,
52
+ loading,
53
+ loadError,
54
+ search,
55
+ page,
56
+ perPage,
57
+ editorOpen,
58
+ editorMode,
59
+ editing,
60
+ saving,
61
+ editorError,
62
+ deleting,
63
+ deleteBusy,
64
+ deleteError,
65
+ filteredRecords,
66
+ paginatedRecords,
67
+ goToPage,
68
+ handleSearch,
69
+ openCreate,
70
+ openEdit,
71
+ closeEditor,
72
+ requestDelete,
73
+ closeDelete,
74
+ upsert,
75
+ remove,
76
+ clampPage,
77
+ } = useRecordsDashboard<CourierRecord>({ filter: matchesQuery })
78
+
38
79
  const users = state<UserOption[]>([])
39
- const loading = state(true)
40
- const loadError = state('')
41
- const search = state('')
42
- const page = state(1)
43
- const perPage = state(10)
44
- const editorOpen = state(false)
45
- const editorMode = state<'create' | 'edit'>('create')
46
- const editing = state<CourierRecord | null>(null)
47
- const saving = state(false)
48
- const editorError = state('')
49
- const deleting = state<CourierRecord | null>(null)
50
- const deleteBusy = state(false)
51
- const deleteError = state('')
52
80
 
53
81
  function userFor(id?: number | null): UserOption | undefined {
54
82
  return id ? users().find(user => user.id === id) : undefined
@@ -61,26 +89,6 @@ function withUser(record: CourierRecord): CourierRecord {
61
89
  }
62
90
  }
63
91
 
64
- function filteredRecords(): CourierRecord[] {
65
- const query = search().trim().toLowerCase()
66
- if (!query)
67
- return records()
68
- return records().filter(record => [
69
- record.name,
70
- record.phone,
71
- record.vehicle_number,
72
- record.license,
73
- record.status,
74
- record.user?.name,
75
- record.user?.email,
76
- ].some(value => String(value || '').toLowerCase().includes(query)))
77
- }
78
-
79
- function paginatedRecords(): CourierRecord[] {
80
- const start = (page() - 1) * perPage()
81
- return filteredRecords().slice(start, start + perPage())
82
- }
83
-
84
92
  function statusCount(status: CourierStatus): number {
85
93
  return records().filter(record => record.status === status).length
86
94
  }
@@ -106,9 +114,7 @@ async function loadCouriers(): Promise<void> {
106
114
  throw new TypeError('Courier resources were not returned in the expected shape.')
107
115
  users.set(userData.users)
108
116
  records.set(courierData.map(withUser))
109
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
110
- if (page() > lastPage)
111
- page.set(lastPage)
117
+ clampPage()
112
118
  }
113
119
  catch (error) {
114
120
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -119,33 +125,6 @@ async function loadCouriers(): Promise<void> {
119
125
  }
120
126
  }
121
127
 
122
- function handleSearch(value: string): void {
123
- search.set(value)
124
- page.set(1)
125
- }
126
-
127
- function openCreate(): void {
128
- editing.set(null)
129
- editorMode.set('create')
130
- editorError.set('')
131
- editorOpen.set(true)
132
- }
133
-
134
- function openEdit(record: CourierRecord): void {
135
- editing.set(record)
136
- editorMode.set('edit')
137
- editorError.set('')
138
- editorOpen.set(true)
139
- }
140
-
141
- function closeEditor(): void {
142
- if (saving())
143
- return
144
- editorOpen.set(false)
145
- editing.set(null)
146
- editorError.set('')
147
- }
148
-
149
128
  function setEditorError(message: string): void {
150
129
  editorError.set(message)
151
130
  }
@@ -165,14 +144,7 @@ async function saveCourier(payload: CourierPayload): Promise<void> {
165
144
  },
166
145
  )
167
146
  const hydrated = withUser(updated)
168
- if (current) {
169
- records.set(records().map(record => record.id === current.id ? hydrated : record))
170
- }
171
- else {
172
- const nextRecords = [...records(), hydrated]
173
- records.set(nextRecords)
174
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
175
- }
147
+ upsert(hydrated, current)
176
148
  editorOpen.set(false)
177
149
  editing.set(null)
178
150
  pushToast('success', current ? 'Courier updated' : 'Courier added', { detail: hydrated.name })
@@ -185,18 +157,6 @@ async function saveCourier(payload: CourierPayload): Promise<void> {
185
157
  }
186
158
  }
187
159
 
188
- function requestDelete(record: CourierRecord): void {
189
- deleting.set(record)
190
- deleteError.set('')
191
- }
192
-
193
- function closeDelete(): void {
194
- if (deleteBusy())
195
- return
196
- deleting.set(null)
197
- deleteError.set('')
198
- }
199
-
200
160
  async function confirmDelete(): Promise<void> {
201
161
  const current = deleting()
202
162
  if (!current)
@@ -207,11 +167,8 @@ async function confirmDelete(): Promise<void> {
207
167
  await dashboardApi(`/api/dashboard/commerce/couriers/${current.id}`, {
208
168
  method: 'DELETE',
209
169
  })
210
- records.set(records().filter(record => record.id !== current.id))
170
+ remove(current)
211
171
  deleting.set(null)
212
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
213
- if (page() > lastPage)
214
- page.set(lastPage)
215
172
  pushToast('success', 'Courier deleted', { detail: current.name })
216
173
  }
217
174
  catch (error) {
@@ -222,10 +179,6 @@ async function confirmDelete(): Promise<void> {
222
179
  }
223
180
  }
224
181
 
225
- function goToPage(value: number): void {
226
- page.set(value)
227
- }
228
-
229
182
  onMount(() => {
230
183
  void loadCouriers()
231
184
  })
@@ -234,22 +187,16 @@ onMount(() => {
234
187
  <div class="space-y-6">
235
188
  <TabNavigation model-value="couriers" />
236
189
 
237
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
238
- <div>
239
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Delivery couriers</h1>
240
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage courier availability, vehicles, licenses, and optional user accounts.</p>
241
- </div>
242
- <div class="flex gap-2">
243
- <Button :loading="loading()" variant="secondary" @click="loadCouriers()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
244
- <Button :disabled="loading()" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add courier</Button>
245
- </div>
246
- </header>
190
+ <PageHeader title="Delivery couriers" description="Manage courier availability, vehicles, licenses, and optional user accounts.">
191
+ <div class="flex gap-2">
192
+ <Button :loading="loading()" variant="secondary" @click="loadCouriers()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
193
+ <Button :disabled="loading()" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add courier</Button>
194
+ </div>
195
+ </PageHeader>
247
196
 
248
197
  <div :if="loadError()" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadCouriers()">Retry</Button></div>
249
198
 
250
- <section aria-label="Courier metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
251
- <template :for="item in metricCards()" :key="item.label"><article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p></article></template>
252
- </section>
199
+ <MetricCards label="Courier metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
253
200
 
254
201
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
255
202
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
@@ -142,22 +142,18 @@ onMount(() => {
142
142
  <div class="space-y-6">
143
143
  <TabNavigation model-value="overview" />
144
144
 
145
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
146
- <div>
147
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Delivery</h1>
148
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Monitor active routes, courier availability, shipping methods, and coverage.</p>
149
- </div>
150
- <div class="flex gap-2">
151
- <Button :loading="loading()" variant="secondary" @click="loadOverview()">
152
- <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
153
- Refresh
154
- </Button>
155
- <Button href="/commerce/delivery/delivery-routes">
156
- <span aria-hidden="true" class="h-4 w-4 i-hugeicons-route-02"></span>
157
- Manage routes
158
- </Button>
159
- </div>
160
- </header>
145
+ <PageHeader title="Delivery" description="Monitor active routes, courier availability, shipping methods, and coverage.">
146
+ <div class="flex gap-2">
147
+ <Button :loading="loading()" variant="secondary" @click="loadOverview()">
148
+ <span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>
149
+ Refresh
150
+ </Button>
151
+ <Button href="/commerce/delivery/delivery-routes">
152
+ <span aria-hidden="true" class="h-4 w-4 i-hugeicons-route-02"></span>
153
+ Manage routes
154
+ </Button>
155
+ </div>
156
+ </PageHeader>
161
157
 
162
158
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">
163
159
  <span>{{ loadError() }}</span>
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import { dashboardApi } from '../../../../../functions/dashboard-api'
3
+ import { useRecordsDashboard } from '../../../../../functions/dashboard-list'
3
4
  import { pushToast } from '../../../../../functions/toasts'
4
5
 
5
6
  interface CourierRecord {
@@ -31,21 +32,48 @@ interface DeliveryRoutePayload {
31
32
  lastActive: number
32
33
  }
33
34
 
34
- const records = state<DeliveryRouteRecord[]>([])
35
+ function matchesQuery(records: DeliveryRouteRecord[], query: string): DeliveryRouteRecord[] {
36
+ return records.filter(record => [
37
+ record.id,
38
+ record.courier,
39
+ record.courier_record?.name,
40
+ record.vehicle,
41
+ record.stops,
42
+ record.delivery_time,
43
+ record.total_distance,
44
+ ].some(value => String(value || '').toLowerCase().includes(query)))
45
+ }
46
+
47
+ const {
48
+ records,
49
+ loading,
50
+ loadError,
51
+ search,
52
+ page,
53
+ perPage,
54
+ editorOpen,
55
+ editorMode,
56
+ editing,
57
+ saving,
58
+ editorError,
59
+ deleting,
60
+ deleteBusy,
61
+ deleteError,
62
+ filteredRecords,
63
+ paginatedRecords,
64
+ goToPage,
65
+ handleSearch,
66
+ openCreate,
67
+ openEdit,
68
+ closeEditor,
69
+ requestDelete,
70
+ closeDelete,
71
+ upsert,
72
+ remove,
73
+ clampPage,
74
+ } = useRecordsDashboard<DeliveryRouteRecord>({ filter: matchesQuery })
75
+
35
76
  const couriers = state<CourierRecord[]>([])
36
- const loading = state(true)
37
- const loadError = state('')
38
- const search = state('')
39
- const page = state(1)
40
- const perPage = state(10)
41
- const editorOpen = state(false)
42
- const editorMode = state<'create' | 'edit'>('create')
43
- const editing = state<DeliveryRouteRecord | null>(null)
44
- const saving = state(false)
45
- const editorError = state('')
46
- const deleting = state<DeliveryRouteRecord | null>(null)
47
- const deleteBusy = state(false)
48
- const deleteError = state('')
49
77
 
50
78
  function courierFor(id?: number | null): CourierRecord | undefined {
51
79
  return id ? couriers().find(courier => courier.id === id) : undefined
@@ -64,26 +92,6 @@ function routeTime(value: string | number): number {
64
92
  return new Date(String(value).replace(' ', 'T')).getTime()
65
93
  }
66
94
 
67
- function filteredRecords(): DeliveryRouteRecord[] {
68
- const query = search().trim().toLowerCase()
69
- if (!query)
70
- return records()
71
- return records().filter(record => [
72
- record.id,
73
- record.courier,
74
- record.courier_record?.name,
75
- record.vehicle,
76
- record.stops,
77
- record.delivery_time,
78
- record.total_distance,
79
- ].some(value => String(value || '').toLowerCase().includes(query)))
80
- }
81
-
82
- function paginatedRecords(): DeliveryRouteRecord[] {
83
- const start = (page() - 1) * perPage()
84
- return filteredRecords().slice(start, start + perPage())
85
- }
86
-
87
95
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
88
96
  const totalStops = records().reduce((sum, record) => sum + record.stops, 0)
89
97
  const totalDistance = records().reduce((sum, record) => sum + record.total_distance, 0)
@@ -109,9 +117,7 @@ async function loadRoutes(): Promise<void> {
109
117
  throw new TypeError('Delivery route resources were not returned in the expected shape.')
110
118
  couriers.set(courierData)
111
119
  records.set(routeData.map(withCourier))
112
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
113
- if (page() > lastPage)
114
- page.set(lastPage)
120
+ clampPage()
115
121
  }
116
122
  catch (error) {
117
123
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -122,33 +128,6 @@ async function loadRoutes(): Promise<void> {
122
128
  }
123
129
  }
124
130
 
125
- function handleSearch(value: string): void {
126
- search.set(value)
127
- page.set(1)
128
- }
129
-
130
- function openCreate(): void {
131
- editing.set(null)
132
- editorMode.set('create')
133
- editorError.set('')
134
- editorOpen.set(true)
135
- }
136
-
137
- function openEdit(record: DeliveryRouteRecord): void {
138
- editing.set(record)
139
- editorMode.set('edit')
140
- editorError.set('')
141
- editorOpen.set(true)
142
- }
143
-
144
- function closeEditor(): void {
145
- if (saving())
146
- return
147
- editorOpen.set(false)
148
- editing.set(null)
149
- editorError.set('')
150
- }
151
-
152
131
  function setEditorError(message: string): void {
153
132
  editorError.set(message)
154
133
  }
@@ -168,14 +147,7 @@ async function saveRoute(payload: DeliveryRoutePayload): Promise<void> {
168
147
  },
169
148
  )
170
149
  const hydrated = withCourier(updated)
171
- if (current) {
172
- records.set(records().map(record => record.id === current.id ? hydrated : record))
173
- }
174
- else {
175
- const nextRecords = [...records(), hydrated]
176
- records.set(nextRecords)
177
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
178
- }
150
+ upsert(hydrated, current)
179
151
  editorOpen.set(false)
180
152
  editing.set(null)
181
153
  pushToast('success', current ? 'Delivery route updated' : 'Delivery route added', { detail: `Route #${hydrated.id}` })
@@ -188,18 +160,6 @@ async function saveRoute(payload: DeliveryRoutePayload): Promise<void> {
188
160
  }
189
161
  }
190
162
 
191
- function requestDelete(record: DeliveryRouteRecord): void {
192
- deleting.set(record)
193
- deleteError.set('')
194
- }
195
-
196
- function closeDelete(): void {
197
- if (deleteBusy())
198
- return
199
- deleting.set(null)
200
- deleteError.set('')
201
- }
202
-
203
163
  async function confirmDelete(): Promise<void> {
204
164
  const current = deleting()
205
165
  if (!current)
@@ -210,11 +170,9 @@ async function confirmDelete(): Promise<void> {
210
170
  await dashboardApi(`/api/dashboard/commerce/delivery-routes/${current.id}`, {
211
171
  method: 'DELETE',
212
172
  })
213
- records.set(records().filter(record => record.id !== current.id))
173
+ remove(current)
214
174
  deleting.set(null)
215
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
216
- if (page() > lastPage)
217
- page.set(lastPage)
175
+ clampPage()
218
176
  pushToast('success', 'Delivery route deleted', { detail: `Route #${current.id}` })
219
177
  }
220
178
  catch (error) {
@@ -225,10 +183,6 @@ async function confirmDelete(): Promise<void> {
225
183
  }
226
184
  }
227
185
 
228
- function goToPage(value: number): void {
229
- page.set(value)
230
- }
231
-
232
186
  onMount(() => {
233
187
  void loadRoutes()
234
188
  })
@@ -237,23 +191,17 @@ onMount(() => {
237
191
  <div class="space-y-6">
238
192
  <TabNavigation model-value="routes" />
239
193
 
240
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
241
- <div>
242
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Delivery routes</h1>
243
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Assign persisted couriers and track route workload, distance, and activity.</p>
244
- </div>
245
- <div class="flex gap-2">
246
- <Button :loading="loading()" variant="secondary" @click="loadRoutes()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
247
- <Button :disabled="loading() || couriers().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add route</Button>
248
- </div>
249
- </header>
194
+ <PageHeader title="Delivery routes" description="Assign persisted couriers and track route workload, distance, and activity.">
195
+ <div class="flex gap-2">
196
+ <Button :loading="loading()" variant="secondary" @click="loadRoutes()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
197
+ <Button :disabled="loading() || couriers().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add route</Button>
198
+ </div>
199
+ </PageHeader>
250
200
 
251
201
  <div :if="loadError()" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadRoutes()">Retry</Button></div>
252
202
  <div :if="!loading() && couriers().length === 0" class="p-4 text-amber-800 text-sm dark:text-amber-200 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900">Add a courier before creating a delivery route.</div>
253
203
 
254
- <section aria-label="Delivery route metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
255
- <template :for="item in metricCards()" :key="item.label"><article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p></article></template>
256
- </section>
204
+ <MetricCards label="Delivery route metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
257
205
 
258
206
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
259
207
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
@@ -202,22 +202,16 @@ onMount(() => {
202
202
  <div class="space-y-6">
203
203
  <TabNavigation model-value="digital" />
204
204
 
205
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
206
- <div>
207
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Digital delivery</h1>
208
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Control file access, download limits, expiry, and automatic fulfillment.</p>
209
- </div>
210
- <div class="flex gap-2">
211
- <Button :loading="loading()" variant="secondary" @click="loadMethods()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
212
- <Button :disabled="loading()" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add method</Button>
213
- </div>
214
- </header>
205
+ <PageHeader title="Digital delivery" description="Control file access, download limits, expiry, and automatic fulfillment.">
206
+ <div class="flex gap-2">
207
+ <Button :loading="loading()" variant="secondary" @click="loadMethods()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
208
+ <Button :disabled="loading()" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add method</Button>
209
+ </div>
210
+ </PageHeader>
215
211
 
216
212
  <div :if="loadError()" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadMethods()">Retry</Button></div>
217
213
 
218
- <section aria-label="Digital delivery metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
219
- <template :for="item in metricCards()" :key="item.label"><article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">{{ item.label }}</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ item.detail }}</p></article></template>
220
- </section>
214
+ <MetricCards label="Digital delivery metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
221
215
 
222
216
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
223
217
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 border-b border-gray-200 dark:border-neutral-700">