@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 LicenseKeyStatus = 'active' | 'inactive' | 'unassigned'
@@ -42,21 +43,48 @@ interface LicenseKeyPayload {
42
43
  }
43
44
 
44
45
  const emptyOptions: LicenseKeyOptions = { customers: [], products: [], orders: [] }
45
- const records = state<LicenseKeyRecord[]>([])
46
+ function matchesQuery(records: LicenseKeyRecord[], query: string): LicenseKeyRecord[] {
47
+ return records.filter(record => [
48
+ record.key,
49
+ record.template,
50
+ record.status,
51
+ record.customer?.label,
52
+ record.customer?.detail,
53
+ record.product?.label,
54
+ record.order?.label,
55
+ ].some(value => String(value || '').toLowerCase().includes(query)))
56
+ }
57
+
58
+ const {
59
+ records,
60
+ loading,
61
+ loadError,
62
+ search,
63
+ page,
64
+ perPage,
65
+ editorOpen,
66
+ editorMode,
67
+ editing,
68
+ saving,
69
+ editorError,
70
+ deleting,
71
+ deleteBusy,
72
+ deleteError,
73
+ filteredRecords,
74
+ paginatedRecords,
75
+ goToPage,
76
+ handleSearch,
77
+ openCreate,
78
+ openEdit,
79
+ closeEditor,
80
+ requestDelete,
81
+ closeDelete,
82
+ upsert,
83
+ remove,
84
+ clampPage,
85
+ } = useRecordsDashboard<LicenseKeyRecord>({ filter: matchesQuery })
86
+
46
87
  const options = state<LicenseKeyOptions>(emptyOptions)
47
- const loading = state(true)
48
- const loadError = state('')
49
- const search = state('')
50
- const page = state(1)
51
- const perPage = state(10)
52
- const editorOpen = state(false)
53
- const editorMode = state<'create' | 'edit'>('create')
54
- const editing = state<LicenseKeyRecord | null>(null)
55
- const saving = state(false)
56
- const editorError = state('')
57
- const deleting = state<LicenseKeyRecord | null>(null)
58
- const deleteBusy = state(false)
59
- const deleteError = state('')
60
88
 
61
89
  function optionFor(items: RelatedSummary[], id?: number | null): RelatedSummary | null {
62
90
  return id ? items.find(item => item.id === id) || null : null
@@ -78,26 +106,6 @@ function dateTime(value: string): number {
78
106
  return new Date(value.replace(' ', 'T')).getTime()
79
107
  }
80
108
 
81
- function filteredRecords(): LicenseKeyRecord[] {
82
- const query = search().trim().toLowerCase()
83
- if (!query)
84
- return records()
85
- return records().filter(record => [
86
- record.key,
87
- record.template,
88
- record.status,
89
- record.customer?.label,
90
- record.customer?.detail,
91
- record.product?.label,
92
- record.order?.label,
93
- ].some(value => String(value || '').toLowerCase().includes(query)))
94
- }
95
-
96
- function paginatedRecords(): LicenseKeyRecord[] {
97
- const start = (page() - 1) * perPage()
98
- return filteredRecords().slice(start, start + perPage())
99
- }
100
-
101
109
  function metricCards(): Array<{ label: string, value: string, detail: string }> {
102
110
  const expiryCutoff = Date.now() + (30 * 24 * 60 * 60 * 1000)
103
111
  const expiring = records().filter((record) => {
@@ -124,9 +132,7 @@ async function loadKeys(): Promise<void> {
124
132
  throw new TypeError('License key resources were not returned in the expected shape.')
125
133
  options.set(optionData)
126
134
  records.set(keyData.map(withRelationships))
127
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
128
- if (page() > lastPage)
129
- page.set(lastPage)
135
+ clampPage()
130
136
  }
131
137
  catch (error) {
132
138
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -137,33 +143,6 @@ async function loadKeys(): Promise<void> {
137
143
  }
138
144
  }
139
145
 
140
- function handleSearch(value: string): void {
141
- search.set(value)
142
- page.set(1)
143
- }
144
-
145
- function openCreate(): void {
146
- editing.set(null)
147
- editorMode.set('create')
148
- editorError.set('')
149
- editorOpen.set(true)
150
- }
151
-
152
- function openEdit(record: LicenseKeyRecord): void {
153
- editing.set(record)
154
- editorMode.set('edit')
155
- editorError.set('')
156
- editorOpen.set(true)
157
- }
158
-
159
- function closeEditor(): void {
160
- if (saving())
161
- return
162
- editorOpen.set(false)
163
- editing.set(null)
164
- editorError.set('')
165
- }
166
-
167
146
  function setEditorError(message: string): void {
168
147
  editorError.set(message)
169
148
  }
@@ -183,14 +162,7 @@ async function saveKey(payload: LicenseKeyPayload): Promise<void> {
183
162
  },
184
163
  )
185
164
  const hydrated = withRelationships(updated)
186
- if (current) {
187
- records.set(records().map(record => record.id === current.id ? hydrated : record))
188
- }
189
- else {
190
- const nextRecords = [...records(), hydrated]
191
- records.set(nextRecords)
192
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
193
- }
165
+ upsert(hydrated, current)
194
166
  editorOpen.set(false)
195
167
  editing.set(null)
196
168
  pushToast('success', current ? 'License key updated' : 'License key added', { detail: hydrated.key })
@@ -203,18 +175,6 @@ async function saveKey(payload: LicenseKeyPayload): Promise<void> {
203
175
  }
204
176
  }
205
177
 
206
- function requestDelete(record: LicenseKeyRecord): void {
207
- deleting.set(record)
208
- deleteError.set('')
209
- }
210
-
211
- function closeDelete(): void {
212
- if (deleteBusy())
213
- return
214
- deleting.set(null)
215
- deleteError.set('')
216
- }
217
-
218
178
  async function confirmDelete(): Promise<void> {
219
179
  const current = deleting()
220
180
  if (!current)
@@ -225,11 +185,9 @@ async function confirmDelete(): Promise<void> {
225
185
  await dashboardApi(`/api/dashboard/commerce/license-keys/${current.id}`, {
226
186
  method: 'DELETE',
227
187
  })
228
- records.set(records().filter(record => record.id !== current.id))
188
+ remove(current)
229
189
  deleting.set(null)
230
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
231
- if (page() > lastPage)
232
- page.set(lastPage)
190
+ clampPage()
233
191
  pushToast('success', 'License key deleted', { detail: current.key })
234
192
  }
235
193
  catch (error) {
@@ -240,10 +198,6 @@ async function confirmDelete(): Promise<void> {
240
198
  }
241
199
  }
242
200
 
243
- function goToPage(value: number): void {
244
- page.set(value)
245
- }
246
-
247
201
  onMount(() => {
248
202
  void loadKeys()
249
203
  })
@@ -252,22 +206,16 @@ onMount(() => {
252
206
  <div class="space-y-6">
253
207
  <TabNavigation model-value="license" />
254
208
 
255
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
256
- <div>
257
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">License keys</h1>
258
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Generate unique software licenses and manage their commerce assignments.</p>
259
- </div>
260
- <div class="flex gap-2">
261
- <Button :loading="loading()" variant="secondary" @click="loadKeys()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
262
- <Button :disabled="loading()" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add key</Button>
263
- </div>
264
- </header>
209
+ <PageHeader title="License keys" description="Generate unique software licenses and manage their commerce assignments.">
210
+ <div class="flex gap-2">
211
+ <Button :loading="loading()" variant="secondary" @click="loadKeys()"><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 key</Button>
213
+ </div>
214
+ </PageHeader>
265
215
 
266
216
  <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="loadKeys()">Retry</Button></div>
267
217
 
268
- <section aria-label="License key metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
269
- <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>
270
- </section>
218
+ <MetricCards label="License key metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
271
219
 
272
220
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
273
221
  <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">
@@ -209,22 +209,16 @@ onMount(() => {
209
209
  <div class="space-y-6">
210
210
  <TabNavigation model-value="methods" />
211
211
 
212
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
213
- <div>
214
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Shipping methods</h1>
215
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage persisted rates, activation state, and shipping-zone coverage.</p>
216
- </div>
217
- <div class="flex gap-2">
218
- <Button :loading="loading()" variant="secondary" @click="loadMethods()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
219
- <Button @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add method</Button>
220
- </div>
221
- </header>
212
+ <PageHeader title="Shipping methods" description="Manage persisted rates, activation state, and shipping-zone coverage.">
213
+ <div class="flex gap-2">
214
+ <Button :loading="loading()" variant="secondary" @click="loadMethods()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
215
+ <Button @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add method</Button>
216
+ </div>
217
+ </PageHeader>
222
218
 
223
219
  <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>
224
220
 
225
- <section aria-label="Shipping method metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-3">
226
- <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>
227
- </section>
221
+ <MetricCards label="Shipping method metrics" columns="grid-cols-1 sm:grid-cols-3" :items="metricCards()" />
228
222
 
229
223
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
230
224
  <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">
@@ -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
  import { formatCurrency, minorToInput } from '@stacksjs/commerce/money'
5
6
 
@@ -28,22 +29,47 @@ interface ShippingRatePayload {
28
29
  rate: number
29
30
  }
30
31
 
31
- const records = state<ShippingRateRecord[]>([])
32
+ function matchesQuery(records: ShippingRateRecord[], query: string): ShippingRateRecord[] {
33
+ return records.filter(record => [
34
+ record.shipping_method?.name,
35
+ record.shipping_zone?.name,
36
+ record.weight_from,
37
+ record.weight_to,
38
+ minorToInput(record.rate, 'USD'),
39
+ ].some(value => String(value ?? '').toLowerCase().includes(query)))
40
+ }
41
+
42
+ const {
43
+ records,
44
+ loading,
45
+ loadError,
46
+ search,
47
+ page,
48
+ perPage,
49
+ editorOpen,
50
+ editorMode,
51
+ editing,
52
+ saving,
53
+ editorError,
54
+ deleting,
55
+ deleteBusy,
56
+ deleteError,
57
+ filteredRecords,
58
+ paginatedRecords,
59
+ goToPage,
60
+ handleSearch,
61
+ openCreate,
62
+ openEdit,
63
+ closeEditor,
64
+ requestDelete,
65
+ closeDelete,
66
+ upsert,
67
+ remove,
68
+ clampPage,
69
+ } = useRecordsDashboard<ShippingRateRecord>({ filter: matchesQuery })
70
+
32
71
  const methods = state<ShippingResourceOption[]>([])
33
72
  const zones = state<ShippingResourceOption[]>([])
34
- const loading = state(true)
35
- const loadError = state('')
36
- const search = state('')
37
- const page = state(1)
38
- const perPage = state(10)
39
- const editorOpen = state(false)
40
- const editorMode = state<'create' | 'edit'>('create')
41
- const editing = state<ShippingRateRecord | null>(null)
42
- const saving = state(false)
43
- const editorError = state('')
44
- const deleting = state<ShippingRateRecord | null>(null)
45
- const deleteBusy = state(false)
46
- const deleteError = state('')
47
73
 
48
74
  function methodFor(id: number): ShippingResourceOption | undefined {
49
75
  return methods().find(method => method.id === id)
@@ -61,24 +87,6 @@ function withRelations(record: ShippingRateRecord): ShippingRateRecord {
61
87
  }
62
88
  }
63
89
 
64
- function filteredRecords(): ShippingRateRecord[] {
65
- const query = search().trim().toLowerCase()
66
- if (!query)
67
- return records()
68
- return records().filter(record => [
69
- record.shipping_method?.name,
70
- record.shipping_zone?.name,
71
- record.weight_from,
72
- record.weight_to,
73
- minorToInput(record.rate, 'USD'),
74
- ].some(value => String(value ?? '').toLowerCase().includes(query)))
75
- }
76
-
77
- function paginatedRecords(): ShippingRateRecord[] {
78
- const start = (page() - 1) * perPage()
79
- return filteredRecords().slice(start, start + perPage())
80
- }
81
-
82
90
  function uniqueZoneCount(): number {
83
91
  return new Set(records().map(record => record.shipping_zone_id)).size
84
92
  }
@@ -114,9 +122,7 @@ async function loadRates(): Promise<void> {
114
122
  methods.set(methodData)
115
123
  zones.set(zoneData)
116
124
  records.set(rateData.map(withRelations))
117
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
118
- if (page() > lastPage)
119
- page.set(lastPage)
125
+ clampPage()
120
126
  }
121
127
  catch (error) {
122
128
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -127,33 +133,6 @@ async function loadRates(): Promise<void> {
127
133
  }
128
134
  }
129
135
 
130
- function handleSearch(value: string): void {
131
- search.set(value)
132
- page.set(1)
133
- }
134
-
135
- function openCreate(): void {
136
- editing.set(null)
137
- editorMode.set('create')
138
- editorError.set('')
139
- editorOpen.set(true)
140
- }
141
-
142
- function openEdit(record: ShippingRateRecord): void {
143
- editing.set(record)
144
- editorMode.set('edit')
145
- editorError.set('')
146
- editorOpen.set(true)
147
- }
148
-
149
- function closeEditor(): void {
150
- if (saving())
151
- return
152
- editorOpen.set(false)
153
- editing.set(null)
154
- editorError.set('')
155
- }
156
-
157
136
  function setEditorError(message: string): void {
158
137
  editorError.set(message)
159
138
  }
@@ -173,14 +152,7 @@ async function saveRate(payload: ShippingRatePayload): Promise<void> {
173
152
  },
174
153
  )
175
154
  const hydrated = withRelations(updated)
176
- if (current) {
177
- records.set(records().map(record => record.id === current.id ? hydrated : record))
178
- }
179
- else {
180
- const nextRecords = [...records(), hydrated]
181
- records.set(nextRecords)
182
- page.set(Math.max(1, Math.ceil(nextRecords.length / perPage())))
183
- }
155
+ upsert(hydrated, current)
184
156
  editorOpen.set(false)
185
157
  editing.set(null)
186
158
  pushToast('success', current ? 'Shipping rate updated' : 'Shipping rate added', {
@@ -195,18 +167,6 @@ async function saveRate(payload: ShippingRatePayload): Promise<void> {
195
167
  }
196
168
  }
197
169
 
198
- function requestDelete(record: ShippingRateRecord): void {
199
- deleting.set(record)
200
- deleteError.set('')
201
- }
202
-
203
- function closeDelete(): void {
204
- if (deleteBusy())
205
- return
206
- deleting.set(null)
207
- deleteError.set('')
208
- }
209
-
210
170
  async function confirmDelete(): Promise<void> {
211
171
  const current = deleting()
212
172
  if (!current)
@@ -217,11 +177,9 @@ async function confirmDelete(): Promise<void> {
217
177
  await dashboardApi(`/api/dashboard/commerce/shipping-rates/${current.id}`, {
218
178
  method: 'DELETE',
219
179
  })
220
- records.set(records().filter(record => record.id !== current.id))
180
+ remove(current)
221
181
  deleting.set(null)
222
- const lastPage = Math.max(1, Math.ceil(filteredRecords().length / perPage()))
223
- if (page() > lastPage)
224
- page.set(lastPage)
182
+ clampPage()
225
183
  pushToast('success', 'Shipping rate deleted', {
226
184
  detail: `${current.shipping_method?.name || 'Method'} for ${current.shipping_zone?.name || 'zone'}`,
227
185
  })
@@ -234,10 +192,6 @@ async function confirmDelete(): Promise<void> {
234
192
  }
235
193
  }
236
194
 
237
- function goToPage(value: number): void {
238
- page.set(value)
239
- }
240
-
241
195
  onMount(() => {
242
196
  void loadRates()
243
197
  })
@@ -246,23 +200,17 @@ onMount(() => {
246
200
  <div class="space-y-6">
247
201
  <TabNavigation model-value="rates" />
248
202
 
249
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
250
- <div>
251
- <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Shipping rates</h1>
252
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Price method and zone combinations across exact weight bands.</p>
253
- </div>
254
- <div class="flex gap-2">
255
- <Button :loading="loading()" variant="secondary" @click="loadRates()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
256
- <Button :disabled="loading() || methods().length === 0 || zones().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add rate</Button>
257
- </div>
258
- </header>
203
+ <PageHeader title="Shipping rates" description="Price method and zone combinations across exact weight bands.">
204
+ <div class="flex gap-2">
205
+ <Button :loading="loading()" variant="secondary" @click="loadRates()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
206
+ <Button :disabled="loading() || methods().length === 0 || zones().length === 0" @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>Add rate</Button>
207
+ </div>
208
+ </PageHeader>
259
209
 
260
210
  <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="loadRates()">Retry</Button></div>
261
211
  <div :if="!loading() && (!methods().length || !zones().length)" 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 at least one shipping method and one shipping zone before creating a rate.</div>
262
212
 
263
- <section aria-label="Shipping rate metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
264
- <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>
265
- </section>
213
+ <MetricCards label="Shipping rate metrics" columns="grid-cols-1 sm:grid-cols-2 xl:grid-cols-4" :items="metricCards()" />
266
214
 
267
215
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700">
268
216
  <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">