@stacksjs/defaults 0.74.2 → 0.74.4

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 (40) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-buddy/SKILL.md +53 -3
  3. package/ai/skills/stacks-cloud/SKILL.md +83 -11
  4. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  5. package/ai/skills/stacks-composables/SKILL.md +1 -1
  6. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  7. package/ai/skills/stacks-deploy/SKILL.md +97 -24
  8. package/ai/skills/stacks-orm/SKILL.md +1 -1
  9. package/ai/skills/stacks-types/SKILL.md +1 -1
  10. package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
  11. package/app/Actions/Buddy/CommandsAction.ts +1 -10
  12. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
  13. package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
  14. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
  15. package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
  16. package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
  17. package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
  18. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
  19. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
  20. package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
  21. package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
  23. package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
  24. package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
  25. package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
  26. package/app/Actions/Monitoring/error-provider.ts +130 -0
  27. package/app/Models/EmailIdempotency.ts +4 -1
  28. package/app/Models/EmailSuppression.ts +4 -1
  29. package/app/Models/EmailWebhookEvent.ts +4 -1
  30. package/app/Models/Request.ts +4 -1
  31. package/functions/api-url.test.ts +61 -0
  32. package/functions/api-url.ts +19 -2
  33. package/ide/vscode/package.json +1 -1
  34. package/package.json +2 -2
  35. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
  36. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
  37. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
  38. package/resources/functions/dashboard/sidebar.ts +1 -0
  39. package/routes/dashboard-api.ts +5 -0
  40. package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
@@ -0,0 +1,130 @@
1
+ import type { DashboardProviderUnavailable } from '../Dashboard/dashboard-provider'
2
+ import { errors } from '@stacksjs/commerce'
3
+ import { HQ_READ_UNAVAILABLE, readThroughProvider, resolveDashboardDriver } from '../Dashboard/dashboard-provider'
4
+
5
+ /**
6
+ * Where the Errors section reads from.
7
+ *
8
+ * Every one of these actions used to call `@stacksjs/commerce` directly, which
9
+ * is how error tracking came to be served by the shopping package: the store
10
+ * lives there for historical reasons and nine dashboard actions grew a
11
+ * dependency on it. Routing the reads through one provider puts that coupling
12
+ * in a single file, so replacing the store later is a change here rather than
13
+ * a change in nine actions.
14
+ *
15
+ * Read paths only. Resolving, ignoring, unresolving and deleting an error stay
16
+ * exactly where they were and always act on the local store. A section reading
17
+ * from a remote provider while writing to a local one would silently disagree
18
+ * with itself, so the write actions are deliberately left untouched until a
19
+ * provider exists that can actually accept a write.
20
+ */
21
+
22
+ /**
23
+ * The record shapes the error store deals in.
24
+ *
25
+ * Derived from the store's own functions rather than imported, because
26
+ * `@stacksjs/commerce` exposes its errors module as a value and keeps these
27
+ * interfaces inside it. Deriving them means the provider contract follows the
28
+ * store automatically, and it keeps this refactor out of the commerce package.
29
+ */
30
+ export type DashboardErrorRecord = NonNullable<Awaited<ReturnType<typeof errors.fetchById>>>
31
+ export type DashboardGroupedError = Awaited<ReturnType<typeof errors.fetchGrouped>>[number]
32
+ export type DashboardErrorStats = Awaited<ReturnType<typeof errors.fetchStats>>
33
+
34
+ export interface DashboardErrorTimelinePoint {
35
+ hour: string
36
+ count: number
37
+ }
38
+
39
+ export interface DashboardErrorsProvider {
40
+ grouped: () => Promise<DashboardGroupedError[]>
41
+ stats: () => Promise<DashboardErrorStats>
42
+ timeline: () => Promise<DashboardErrorTimelinePoint[]>
43
+ byGroup: (type: string, message: string) => Promise<DashboardErrorRecord[]>
44
+ byId: (id: number) => Promise<DashboardErrorRecord | undefined>
45
+ }
46
+
47
+ /** Wraps a payload with the reason its section is empty. */
48
+ export type DashboardErrorsEnvelope<T> = { data: T } & Partial<DashboardProviderUnavailable>
49
+
50
+ /**
51
+ * This application's own error store.
52
+ *
53
+ * Not wrapped in a catch. These actions have never caught a store failure, and
54
+ * adding one here would convert today's error response into a misleading empty
55
+ * list, which is a behaviour change rather than the refactor this is meant to be.
56
+ */
57
+ export const localErrorsProvider: DashboardErrorsProvider = {
58
+ grouped: () => errors.fetchGrouped(),
59
+ stats: () => errors.fetchStats(),
60
+ timeline: () => errors.fetchTimeline(),
61
+ byGroup: (type, message) => errors.fetchByGroup(type, message),
62
+ byId: id => errors.fetchById(id),
63
+ }
64
+
65
+ /**
66
+ * The empty answer for each read, used when a remote provider cannot be reached.
67
+ *
68
+ * `stats` has no natural empty value, so it reports zeros. A section showing
69
+ * zeros next to its reason is clearer than one showing nothing at all.
70
+ */
71
+ const EMPTY_STATS: DashboardErrorStats = {
72
+ total: 0,
73
+ unresolved: 0,
74
+ resolved: 0,
75
+ ignored: 0,
76
+ last_24h: 0,
77
+ trend: 0,
78
+ }
79
+
80
+ async function resolveErrorsProvider(): Promise<{ provider: DashboardErrorsProvider, remote: boolean }> {
81
+ const driver = await resolveDashboardDriver('errors')
82
+ return { provider: localErrorsProvider, remote: driver.name !== 'local' }
83
+ }
84
+
85
+ /**
86
+ * Runs one error read through the configured provider.
87
+ *
88
+ * A local read is handed straight back, failures included. A remote read is
89
+ * guarded, so an unreachable provider yields the empty value plus a reason
90
+ * rather than taking the section down.
91
+ */
92
+ async function readErrors<T>(
93
+ empty: T,
94
+ read: (provider: DashboardErrorsProvider) => Promise<T>,
95
+ isValid: (payload: unknown) => boolean,
96
+ ): Promise<DashboardErrorsEnvelope<T>> {
97
+ const { provider, remote } = await resolveErrorsProvider()
98
+
99
+ if (!remote)
100
+ return { data: await read(provider) }
101
+
102
+ const payload = await readThroughProvider<DashboardErrorsEnvelope<T>>(
103
+ 'errors',
104
+ reason => ({ data: empty, unavailable: reason }),
105
+ async () => ({ data: empty, unavailable: HQ_READ_UNAVAILABLE }),
106
+ candidate => isValid((candidate as DashboardErrorsEnvelope<T>)?.data),
107
+ )
108
+
109
+ return payload
110
+ }
111
+
112
+ export function readGroupedErrors(): Promise<DashboardErrorsEnvelope<DashboardGroupedError[]>> {
113
+ return readErrors([], provider => provider.grouped(), Array.isArray)
114
+ }
115
+
116
+ export function readErrorStats(): Promise<DashboardErrorsEnvelope<DashboardErrorStats>> {
117
+ return readErrors(EMPTY_STATS, provider => provider.stats(), payload => Boolean(payload) && typeof payload === 'object')
118
+ }
119
+
120
+ export function readErrorTimeline(): Promise<DashboardErrorsEnvelope<DashboardErrorTimelinePoint[]>> {
121
+ return readErrors([], provider => provider.timeline(), Array.isArray)
122
+ }
123
+
124
+ export function readErrorsByGroup(type: string, message: string): Promise<DashboardErrorsEnvelope<DashboardErrorRecord[]>> {
125
+ return readErrors([], provider => provider.byGroup(type, message), Array.isArray)
126
+ }
127
+
128
+ export function readErrorById(id: number): Promise<DashboardErrorsEnvelope<DashboardErrorRecord | undefined>> {
129
+ return readErrors<DashboardErrorRecord | undefined>(undefined, provider => provider.byId(id), () => true)
130
+ }
@@ -17,7 +17,10 @@ export default defineModel({
17
17
  useApi: {
18
18
  uri: 'email-idempotency',
19
19
  routes: ['index', 'show', 'destroy'],
20
- middleware: ['auth'],
20
+ // Reads stay as they were; writes need an admin.
21
+ // idempotency keys are what stop a retry from sending twice,
22
+ // so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
23
+ middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
21
24
  },
22
25
  },
23
26
 
@@ -25,7 +25,10 @@ export default defineModel({
25
25
  useApi: {
26
26
  uri: 'email-suppressions',
27
27
  routes: ['index', 'show', 'destroy'],
28
- middleware: ['auth'],
28
+ // Reads stay as they were; writes need an admin.
29
+ // a suppression entry is a compliance record - deleting one re-enables mail to someone who bounced or opted out,
30
+ // so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
31
+ middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
29
32
  },
30
33
  },
31
34
 
@@ -25,7 +25,10 @@ export default defineModel({
25
25
  useApi: {
26
26
  uri: 'email-webhook-events',
27
27
  routes: ['index', 'show', 'destroy'],
28
- middleware: ['auth'],
28
+ // Reads stay as they were; writes need an admin.
29
+ // provider webhook events are the audit trail for what the provider told us,
30
+ // so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
31
+ middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
29
32
  },
30
33
  },
31
34
 
@@ -36,7 +36,10 @@ export default defineModel({
36
36
  useApi: {
37
37
  uri: 'requests',
38
38
  routes: ['index', 'store', 'show', 'update', 'destroy'],
39
- middleware: ['auth'],
39
+ // Reads stay as they were; writes need an admin.
40
+ // request logs are an audit trail, and carry whatever the request carried,
41
+ // so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
42
+ middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
40
43
  },
41
44
  },
42
45
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `resolveApiBaseUrl` must survive a `window` that is not a DOM.
3
+ *
4
+ * It guarded on `typeof window !== 'undefined'` and then read
5
+ * `window.location.origin`. Test harnesses assign `globalThis.window =
6
+ * globalThis` so browser code can be imported off-DOM, and that object has no
7
+ * `location` - so the guard passed and the next property read threw.
8
+ *
9
+ * Callers resolve this at MODULE scope (`monitoring/errors.ts` builds its
10
+ * `baseURL` there), so the throw happened on import: an unhandled error
11
+ * between tests, attributed to no test, which is why it survived a full-suite
12
+ * cleanup that fixed everything with a name (stacksjs/stacks#2421).
13
+ */
14
+ import { afterEach, describe, expect, it } from 'bun:test'
15
+ import { resolveApiBaseUrl } from './api-url'
16
+
17
+ const REAL = {
18
+ window: (globalThis as any).window,
19
+ configured: (globalThis as any).__STACKS_API_URL__,
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const [key, value] of Object.entries({ window: REAL.window, __STACKS_API_URL__: REAL.configured })) {
24
+ if (value === undefined)
25
+ delete (globalThis as any)[key]
26
+ else
27
+ (globalThis as any)[key] = value
28
+ }
29
+ })
30
+
31
+ describe('resolveApiBaseUrl', () => {
32
+ it('falls back to the relative path when window has no location', () => {
33
+ // Exactly what a harness leaves behind: a window that is not a DOM.
34
+ ;(globalThis as any).window = globalThis
35
+ delete (globalThis as any).__STACKS_API_URL__
36
+
37
+ expect(resolveApiBaseUrl()).toBe('/api')
38
+ })
39
+
40
+ it('uses the origin when there is a real one', () => {
41
+ ;(globalThis as any).window = { location: { origin: 'https://app.test' } }
42
+ delete (globalThis as any).__STACKS_API_URL__
43
+
44
+ expect(resolveApiBaseUrl()).toBe('https://app.test/api')
45
+ expect(resolveApiBaseUrl('/v1')).toBe('https://app.test/v1')
46
+ })
47
+
48
+ it('prefers an injected URL over the origin, without its trailing slashes', () => {
49
+ ;(globalThis as any).window = { location: { origin: 'https://app.test' } }
50
+ ;(globalThis as any).__STACKS_API_URL__ = 'https://api.test//'
51
+
52
+ expect(resolveApiBaseUrl()).toBe('https://api.test')
53
+ })
54
+
55
+ it('falls back to the relative path off-DOM entirely', () => {
56
+ delete (globalThis as any).window
57
+ delete (globalThis as any).__STACKS_API_URL__
58
+
59
+ expect(resolveApiBaseUrl()).toBe('/api')
60
+ })
61
+ })
@@ -15,8 +15,25 @@ export function resolveApiBaseUrl(defaultPath = '/api'): string {
15
15
  if (configured)
16
16
  return configured.replace(/\/+$/, '')
17
17
 
18
- if (typeof window !== 'undefined')
19
- return `${window.location.origin}${defaultPath}`
18
+ /*
19
+ * `window.location`, not just `window`.
20
+ *
21
+ * A global named `window` is not proof of a DOM. Test harnesses assign
22
+ * `globalThis.window = globalThis` to make browser code importable off-DOM,
23
+ * and that object has no `location` - so the guard passed and the very next
24
+ * property read threw `undefined is not an object`. Callers compute this at
25
+ * MODULE scope (`monitoring/errors.ts` builds its `baseURL` there), so the
26
+ * throw happened on import, before any code could catch it: it surfaced as an
27
+ * unhandled error between tests, attributed to no test at all
28
+ * (stacksjs/stacks#2421).
29
+ *
30
+ * Reading the origin defensively costs nothing in a real browser and keeps
31
+ * this resolvable anywhere - a worker, SSR, a partial DOM shim - where the
32
+ * honest answer is the relative default.
33
+ */
34
+ const origin = typeof window !== 'undefined' ? window.location?.origin : undefined
35
+ if (origin)
36
+ return `${origin}${defaultPath}`
20
37
 
21
38
  return defaultPath
22
39
  }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.2",
5
+ "version": "0.74.4",
6
6
  "description": "A modern Stacks development environment.",
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.74.2",
5
+ "version": "0.74.4",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -54,7 +54,7 @@
54
54
  "dependencies": {
55
55
  "@iconify-json/f7": "^1.2.2",
56
56
  "@iconify-json/hugeicons": "^1.2.27",
57
- "@stacksjs/mobile": "^0.74.2",
57
+ "@stacksjs/mobile": "^0.74.4",
58
58
  "@stacksjs/sanitizer": "^0.2.113",
59
59
  "ts-qr-codes": "^0.1.8"
60
60
  }
@@ -0,0 +1,240 @@
1
+ <script client>
2
+ import type {
3
+ AbandonedCartIndexPayload,
4
+ AbandonedCartRecord,
5
+ AbandonedCartSummary,
6
+ RecoveryCampaignRecord,
7
+ } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
8
+ import { dashboardApi } from '../../../../functions/dashboard-api'
9
+ import { pushToast } from '../../../../functions/toasts'
10
+
11
+ const emptySummary: AbandonedCartSummary = {
12
+ open: 0,
13
+ openValue: 0,
14
+ contacted: 0,
15
+ recovered: 0,
16
+ recoveredValue: 0,
17
+ recoveryRate: 0,
18
+ averageValue: 0,
19
+ currency: 'USD',
20
+ }
21
+
22
+ const records = state<AbandonedCartRecord[]>([])
23
+ const summary = state<AbandonedCartSummary>({ ...emptySummary })
24
+ const campaigns = state<RecoveryCampaignRecord[]>([])
25
+ /*
26
+ * The shop's configured currency, and the fallback for a page with no carts
27
+ * on it. The carts themselves carry their own, and `summary().currency` is
28
+ * the one the numbers on this screen are actually in - a shop trading in
29
+ * euros should not be shown a dollar sign because the framework's default
30
+ * says USD.
31
+ */
32
+ const defaultCurrency = state('USD')
33
+ const defaultIdleHours = state(4)
34
+ const loading = state(true)
35
+ const loadError = state('')
36
+ const search = state('')
37
+ const stateFilter = state('open')
38
+ const idleFilter = state('all')
39
+ const sort = state('value')
40
+ const page = state(1)
41
+ const perPage = 10
42
+ const dialogOpen = state(false)
43
+ const saving = state(false)
44
+ const saveError = state('')
45
+
46
+ function filteredRecords(): AbandonedCartRecord[] {
47
+ const query = search().trim().toLowerCase()
48
+ const minimumIdle = idleFilter() === 'all' ? 0 : Number(idleFilter())
49
+
50
+ return [...records()]
51
+ .filter((record) => {
52
+ if (stateFilter() === 'open' && record.state === 'recovered')
53
+ return false
54
+ if (stateFilter() === 'cold' && (record.state !== 'abandoned' || record.contacted))
55
+ return false
56
+ if (stateFilter() === 'chased' && !record.contacted)
57
+ return false
58
+ if (stateFilter() === 'recovered' && record.state !== 'recovered')
59
+ return false
60
+ if (record.idleHours < minimumIdle)
61
+ return false
62
+ return !query || [record.customerName, record.customerEmail, record.id, ...record.items]
63
+ .some(value => String(value).toLowerCase().includes(query))
64
+ })
65
+ .sort((left, right) => {
66
+ if (sort() === 'idle')
67
+ return right.idleHours - left.idleHours
68
+ if (sort() === 'customer')
69
+ return left.customerName.localeCompare(right.customerName)
70
+ return right.value - left.value || right.idleHours - left.idleHours
71
+ })
72
+ }
73
+
74
+ function visibleRecords(): AbandonedCartRecord[] {
75
+ const start = (page() - 1) * perPage
76
+ return filteredRecords().slice(start, start + perPage)
77
+ }
78
+
79
+ function totalPages(): number {
80
+ return Math.max(1, Math.ceil(filteredRecords().length / perPage))
81
+ }
82
+
83
+ effect(() => {
84
+ search()
85
+ stateFilter()
86
+ idleFilter()
87
+ sort()
88
+ page.set(1)
89
+ })
90
+
91
+ function money(value: number): string {
92
+ try {
93
+ return new Intl.NumberFormat(undefined, {
94
+ style: 'currency',
95
+ currency: summary().currency || defaultCurrency() || 'USD',
96
+ maximumFractionDigits: 0,
97
+ }).format(value)
98
+ }
99
+ catch {
100
+ return `${Math.round(value)} ${defaultCurrency()}`
101
+ }
102
+ }
103
+
104
+ function percentage(value: number): string {
105
+ return `${value.toFixed(1)}%`
106
+ }
107
+
108
+ function campaignTiming(campaign: RecoveryCampaignRecord): string {
109
+ const stamp = campaign.sentAt || campaign.scheduledAt
110
+ if (!stamp)
111
+ return 'Not scheduled'
112
+ const date = new Date(stamp.replace(' ', 'T'))
113
+ if (!Number.isFinite(date.getTime()))
114
+ return 'Not scheduled'
115
+ const when = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
116
+ return campaign.sentAt ? `Sent ${when}` : `Scheduled ${when}`
117
+ }
118
+
119
+ async function loadCarts(): Promise<void> {
120
+ loading.set(true)
121
+ loadError.set('')
122
+ try {
123
+ const data = await dashboardApi<AbandonedCartIndexPayload>('/api/dashboard/marketing/abandoned-carts')
124
+ if (!data || !Array.isArray(data.records) || !data.summary || !Array.isArray(data.campaigns))
125
+ throw new TypeError('Abandoned cart resources were not returned in the expected shape.')
126
+ records.set(data.records)
127
+ summary.set(data.summary)
128
+ campaigns.set(data.campaigns)
129
+ defaultCurrency.set(data.defaultCurrency || 'USD')
130
+ defaultIdleHours.set(data.defaultIdleHours || 4)
131
+ if (page() > totalPages())
132
+ page.set(totalPages())
133
+ }
134
+ catch (error) {
135
+ loadError.set(error instanceof Error ? error.message : String(error))
136
+ pushToast('error', 'Could not load abandoned carts', { detail: loadError() })
137
+ }
138
+ finally {
139
+ loading.set(false)
140
+ }
141
+ }
142
+
143
+ function openCompose(): void {
144
+ saveError.set('')
145
+ dialogOpen.set(true)
146
+ }
147
+
148
+ function closeCompose(): void {
149
+ if (saving())
150
+ return
151
+ dialogOpen.set(false)
152
+ saveError.set('')
153
+ }
154
+
155
+ async function createCampaign(payload: Record<string, unknown>): Promise<void> {
156
+ saving.set(true)
157
+ saveError.set('')
158
+ try {
159
+ await dashboardApi('/api/dashboard/marketing/abandoned-carts/campaign', { method: 'POST', body: payload })
160
+ dialogOpen.set(false)
161
+ pushToast('success', payload.scheduledAt ? 'Recovery campaign scheduled' : 'Recovery campaign saved as a draft')
162
+ await loadCarts()
163
+ }
164
+ catch (error) {
165
+ saveError.set(error instanceof Error ? error.message : String(error))
166
+ }
167
+ finally {
168
+ saving.set(false)
169
+ }
170
+ }
171
+
172
+ function previousPage(): void {
173
+ if (page() > 1)
174
+ page.set(page() - 1)
175
+ }
176
+
177
+ function nextPage(): void {
178
+ if (page() < totalPages())
179
+ page.set(page() + 1)
180
+ }
181
+
182
+ onMount(() => {
183
+ void loadCarts()
184
+ })
185
+ </script>
186
+
187
+ <div class="space-y-6">
188
+ <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
189
+ <div>
190
+ <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Abandoned Carts</h1>
191
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">The audience that already chose the products. Every other campaign starts by guessing.</p>
192
+ </div>
193
+ <div class="flex gap-2">
194
+ <Button :loading="loading()" variant="secondary" @click="loadCarts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button>
195
+ <Button @click="openCompose()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-mail-send-01"></span>New recovery campaign</Button>
196
+ </div>
197
+ </header>
198
+
199
+ <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"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadCarts()">Retry</Button></div>
200
+
201
+ <section aria-label="Cart recovery metrics" class="grid gap-3 grid-cols-2 lg:grid-cols-5">
202
+ <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">Sitting there</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ summary().open.toLocaleString() }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Carts abandoned or expired</p></article>
203
+ <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">Worth</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ money(summary().openValue) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ money(summary().averageValue) }} average</p></article>
204
+ <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">Chased</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ summary().contacted.toLocaleString() }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Written to and still cold</p></article>
205
+ <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">Recovered</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ money(summary().recoveredValue) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ summary().recovered.toLocaleString() }} carts checked out after an email</p></article>
206
+ <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">Recovery rate</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ percentage(summary().recoveryRate) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">Of the carts actually chased</p></article>
207
+ </section>
208
+
209
+ <section :if="campaigns().length > 0" aria-label="Recovery campaigns" class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
210
+ <h2 class="font-semibold text-gray-900 text-sm dark:text-white">Recovery campaigns</h2>
211
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Ordinary campaigns aimed at cold carts. They send, report and are edited on the campaigns screen like any other.</p>
212
+ <ul class="mt-3 divide-gray-200 divide-y dark:divide-neutral-700">
213
+ <template :for="campaign in campaigns">
214
+ <li class="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between">
215
+ <div>
216
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ campaign.name }}</p>
217
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Carts idle {{ campaign.idleHours }}h or more<span :if="campaign.minimumValue > 0"> · {{ money(campaign.minimumValue) }} and up</span> · {{ campaignTiming(campaign) }}</p>
218
+ </div>
219
+ <div class="flex gap-3 items-center">
220
+ <p class="text-gray-500 text-xs dark:text-neutral-400">{{ campaign.sentCount.toLocaleString() }} sent</p>
221
+ <span class="inline-flex px-2 py-1 font-medium text-xs bg-gray-100 dark:bg-neutral-700 rounded-full">{{ campaign.status }}</span>
222
+ </div>
223
+ </li>
224
+ </template>
225
+ </ul>
226
+ </section>
227
+
228
+ <section class="space-y-3">
229
+ <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
230
+ <label><span class="sr-only">Search carts</span><input x-model="search" type="search" placeholder="Search by customer or product" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700" /></label>
231
+ <label><span class="sr-only">Filter cart state</span><select x-model="stateFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="open">Still out there</option><option value="cold">Never chased</option><option value="chased">Already chased</option><option value="recovered">Recovered</option><option value="all">Everything</option></select></label>
232
+ <label><span class="sr-only">Filter by how long the cart has been idle</span><select x-model="idleFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="all">Any age</option><option value="4">Idle 4h or more</option><option value="24">Idle a day or more</option><option value="72">Idle 3 days or more</option></select></label>
233
+ <label><span class="sr-only">Sort carts</span><select x-model="sort" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="value">Highest value</option><option value="idle">Longest idle</option><option value="customer">Customer</option></select></label>
234
+ </div>
235
+ <AbandonedCartsTable :records="visibleRecords()" :loading="loading()" />
236
+ <div :if="filteredRecords().length > perPage" class="flex items-center justify-between"><p class="text-gray-500 text-sm dark:text-neutral-400">Page {{ page() }} of {{ totalPages() }}</p><div class="flex gap-2"><Button :disabled="page() === 1" variant="secondary" @click="previousPage()">Previous</Button><Button :disabled="page() === totalPages()" variant="secondary" @click="nextPage()">Next</Button></div></div>
237
+ </section>
238
+ </div>
239
+
240
+ <RecoveryCampaignDialog :open="dialogOpen()" :records="records()" :defaultIdleHours="defaultIdleHours()" :defaultCurrency="summary().currency || defaultCurrency()" :busy="saving()" :error="saveError()" @submit="createCampaign" @close="closeCompose()" />
@@ -0,0 +1,96 @@
1
+ <script client>
2
+ import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
3
+
4
+ const records = useReactiveProp('records', [] as AbandonedCartRecord[])
5
+ const loading = useReactiveProp('loading', false)
6
+
7
+ function money(value: number, currency: string): string {
8
+ try {
9
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(value)
10
+ }
11
+ catch {
12
+ // A currency the browser does not know is not a reason to render nothing
13
+ // where a number belongs.
14
+ return `${value.toFixed(2)} ${currency}`
15
+ }
16
+ }
17
+
18
+ /**
19
+ * How long ago, in the units a person would actually say it in.
20
+ *
21
+ * "37.4 hours" is a measurement; "2 days" is how somebody decides whether a
22
+ * cart is still worth chasing.
23
+ */
24
+ function idle(hours: number): string {
25
+ if (hours < 1)
26
+ return 'under an hour'
27
+ if (hours < 48)
28
+ return `${Math.round(hours)}h`
29
+ return `${Math.round(hours / 24)} days`
30
+ }
31
+
32
+ function contents(record: AbandonedCartRecord): string {
33
+ if (record.items.length === 0)
34
+ return `${record.itemCount} item${record.itemCount === 1 ? '' : 's'}`
35
+ const shown = record.items.slice(0, 2).join(', ')
36
+ const rest = record.itemCount - Math.min(2, record.items.length)
37
+ return rest > 0 ? `${shown} +${rest} more` : shown
38
+ }
39
+
40
+ function stateLabel(record: AbandonedCartRecord): string {
41
+ if (record.state === 'recovered')
42
+ return 'Recovered'
43
+ if (record.state === 'expired')
44
+ return 'Expired'
45
+ return record.contacted ? 'Chased' : 'Cold'
46
+ }
47
+
48
+ function stateClass(record: AbandonedCartRecord): string {
49
+ if (record.state === 'recovered')
50
+ return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
51
+ if (record.state === 'expired')
52
+ return 'bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300'
53
+ return record.contacted
54
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
55
+ : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
56
+ }
57
+ </script>
58
+
59
+ <div class="overflow-x-auto bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
60
+ <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
61
+ <thead class="bg-gray-50 dark:bg-neutral-900/50">
62
+ <tr>
63
+ <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Customer</th>
64
+ <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Left behind</th>
65
+ <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Value</th>
66
+ <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Idle</th>
67
+ <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">State</th>
68
+ </tr>
69
+ </thead>
70
+ <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
71
+ <template :if="loading()">
72
+ <tr><td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading carts</td></tr>
73
+ </template>
74
+ <template :else-if="records().length === 0">
75
+ <tr><td colspan="5" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-shopping-cart-01"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No carts to chase</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Nothing matches these filters, which, for once, is the good outcome.</p></td></tr>
76
+ </template>
77
+ <template :else>
78
+ <template :for="record in records">
79
+ <tr class="hover:bg-gray-50/70 dark:hover:bg-neutral-700/40">
80
+ <td class="px-4 py-4 max-w-xs">
81
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.customerName }}</p>
82
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.customerEmail || 'No address on the cart' }}</p>
83
+ </td>
84
+ <td class="px-4 py-4 max-w-sm">
85
+ <p class="text-gray-900 text-sm dark:text-white">{{ contents(record) }}</p>
86
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">Cart #{{ record.id }}</p>
87
+ </td>
88
+ <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm tabular-nums dark:text-white">{{ money(record.value, record.currency) }}</p></td>
89
+ <td class="px-4 py-4"><p class="text-gray-900 text-sm dark:text-white">{{ idle(record.idleHours) }}</p></td>
90
+ <td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + stateClass(record)">{{ stateLabel(record) }}</span></td>
91
+ </tr>
92
+ </template>
93
+ </template>
94
+ </tbody>
95
+ </table>
96
+ </div>