@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,145 @@
1
+ <script client>
2
+ import type { AbandonedCartRecord } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
3
+ import { reachOf } from '../../../../app/Actions/Dashboard/Marketing/abandoned-cart-records'
4
+
5
+ const open = useReactiveProp('open', false)
6
+ const records = useReactiveProp('records', [] as AbandonedCartRecord[])
7
+ const defaultIdleHours = useReactiveProp('defaultIdleHours', 4)
8
+ const defaultCurrency = useReactiveProp('defaultCurrency', 'USD')
9
+ const busy = useReactiveProp('busy', false)
10
+ const error = useReactiveProp('error', '')
11
+ const emit = defineEmits()
12
+
13
+ const name = state('')
14
+ const subject = state('')
15
+ const text = state('')
16
+ const fromName = state('')
17
+ const fromAddress = state('')
18
+ const idleHours = state(4)
19
+ const minimumValue = state(0)
20
+ const scheduledAt = state('')
21
+ let hydrated = false
22
+
23
+ effect(() => {
24
+ if (!open()) {
25
+ hydrated = false
26
+ return
27
+ }
28
+ if (hydrated)
29
+ return
30
+ hydrated = true
31
+ name.set('Cart recovery')
32
+ subject.set('You left something behind')
33
+ text.set('')
34
+ fromName.set('')
35
+ fromAddress.set('')
36
+ idleHours.set(defaultIdleHours())
37
+ minimumValue.set(0)
38
+ scheduledAt.set('')
39
+ })
40
+
41
+ /**
42
+ * Who this would actually be sent to, as the rules are being set.
43
+ *
44
+ * The same function the summary is built from, run against the rows already
45
+ * on the page: a rule that reaches nobody should say so while somebody is
46
+ * still choosing it, not after they have scheduled a send to an empty room.
47
+ */
48
+ function reach(): { carts: number, value: number } {
49
+ return reachOf(records(), Number(idleHours()) || 0, Number(minimumValue()) || 0)
50
+ }
51
+
52
+ function money(value: number): string {
53
+ try {
54
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: defaultCurrency() || 'USD' }).format(value)
55
+ }
56
+ catch {
57
+ return `${value.toFixed(2)} ${defaultCurrency()}`
58
+ }
59
+ }
60
+
61
+ function submit(): void {
62
+ const when = scheduledAt() ? new Date(scheduledAt()) : null
63
+ if (when && !Number.isFinite(when.getTime()))
64
+ return
65
+
66
+ emit('submit', {
67
+ name: name(),
68
+ subject: subject(),
69
+ text: text(),
70
+ fromName: fromName(),
71
+ fromAddress: fromAddress(),
72
+ idleHours: Number(idleHours()) || 0,
73
+ minimumValue: Number(minimumValue()) || 0,
74
+ scheduledAt: when ? when.toISOString() : null,
75
+ currency: defaultCurrency(),
76
+ })
77
+ }
78
+ </script>
79
+
80
+ <Modal :isOpen="open()" title="New recovery campaign" description="Writes to the people whose carts went cold. The audience comes from the carts themselves, not from a list." size="lg" @close="emit('close')">
81
+ <div class="mt-4 space-y-4">
82
+ <div class="grid gap-4 sm:grid-cols-2">
83
+ <label class="block">
84
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Campaign name</span>
85
+ <input x-model="name" type="text" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
86
+ </label>
87
+ <label class="block">
88
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Subject line</span>
89
+ <input x-model="subject" type="text" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
90
+ </label>
91
+ </div>
92
+
93
+ <div class="grid gap-4 sm:grid-cols-2">
94
+ <label class="block">
95
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Wait before writing</span>
96
+ <div class="flex gap-2 items-center mt-1">
97
+ <input x-model="idleHours" type="number" min="1" max="336" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
98
+ <span class="text-gray-500 text-sm dark:text-neutral-400">hours</span>
99
+ </div>
100
+ <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Writing to somebody who is still shopping is not a recovery, it is an interruption.</span>
101
+ </label>
102
+ <label class="block">
103
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Minimum cart value</span>
104
+ <input x-model="minimumValue" type="number" min="0" step="1" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
105
+ <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave at zero to chase every cart.</span>
106
+ </label>
107
+ </div>
108
+
109
+ <div class="grid gap-4 sm:grid-cols-2">
110
+ <label class="block">
111
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">From name</span>
112
+ <input x-model="fromName" type="text" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
113
+ </label>
114
+ <label class="block">
115
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">From address</span>
116
+ <input x-model="fromAddress" type="email" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
117
+ </label>
118
+ </div>
119
+
120
+ <label class="block">
121
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Message</span>
122
+ <textarea x-model="text" rows="3" placeholder="Your cart is still here, and so is everything in it." class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"></textarea>
123
+ </label>
124
+
125
+ <label class="block">
126
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Send at</span>
127
+ <input x-model="scheduledAt" type="datetime-local" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
128
+ <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Leave empty to save it as a draft and send it from the campaigns screen.</span>
129
+ </label>
130
+
131
+ <div class="p-3 bg-gray-50 dark:bg-neutral-900/50 border border-gray-200 rounded-lg dark:border-neutral-700">
132
+ <p class="font-medium text-gray-900 text-sm dark:text-white">{{ reach().carts.toLocaleString() }} carts match these rules</p>
133
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ money(reach().value) }} of goods, counted from the carts on this page. Carts with no address on them cannot be written to and are not included.</p>
134
+ </div>
135
+
136
+ <div :if="error()" role="alert" class="p-3 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">{{ error() }}</div>
137
+ </div>
138
+
139
+ <template #footer>
140
+ <div class="flex gap-2 justify-end w-full">
141
+ <Button :disabled="busy()" variant="secondary" @click="emit('close')">Close</Button>
142
+ <Button :disabled="busy() || !name() || !subject()" :loading="busy()" @click="submit()">{{ scheduledAt() ? 'Schedule campaign' : 'Save as draft' }}</Button>
143
+ </div>
144
+ </template>
145
+ </Modal>
@@ -443,6 +443,7 @@ export function buildNavSections(
443
443
  { to: '/marketing/lists', icon: 'list-settings', text: 'Lists' },
444
444
  { to: '/marketing/social-posts', icon: 'clock', text: 'Social Posts' },
445
445
  { to: '/marketing/campaigns', icon: 'rocket', text: 'Campaigns' },
446
+ { to: '/marketing/abandoned-carts', icon: 'cart', text: 'Abandoned Carts' },
446
447
  { to: '/marketing/reviews', icon: 'star', text: 'Reviews' },
447
448
  ...categoryNavItems(discoveredModels, 'marketing', new Set(['campaign'])),
448
449
  ]])
@@ -413,6 +413,11 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
413
413
  guard(route.post('/marketing/campaigns/{id}/send', 'Actions/Dashboard/Marketing/CampaignSendAction'))
414
414
  guard(route.post('/marketing/campaigns/{id}/schedule', 'Actions/Dashboard/Marketing/CampaignScheduleAction'))
415
415
  guard(route.post('/marketing/campaigns/{id}/cancel', 'Actions/Dashboard/Marketing/CampaignCancelAction'))
416
+ // Abandoned carts: the audience a shop already has, and the campaign that
417
+ // goes after it. The campaign it writes is an ordinary Campaign row, so it
418
+ // shows up on /marketing/campaigns and sends through the same pipeline.
419
+ guard(route.get('/marketing/abandoned-carts', 'Actions/Dashboard/Marketing/AbandonedCartIndexAction'))
420
+ guard(route.post('/marketing/abandoned-carts/campaign', 'Actions/Dashboard/Marketing/AbandonedCartCampaignAction'))
416
421
  guard(route.get('/marketing/social-posts', 'Actions/Dashboard/Marketing/SocialPostIndexAction'))
417
422
  guard(route.post('/marketing/social-posts', 'Actions/Dashboard/Marketing/SocialPostStoreAction'))
418
423
  guard(route.patch('/marketing/social-posts/{id}', 'Actions/Dashboard/Marketing/SocialPostUpdateAction'))
@@ -0,0 +1,10 @@
1
+ @extends('layouts/default')
2
+
3
+ @section('content')
4
+
5
+ @title('Dashboard - Abandoned Carts')
6
+ <PageLayout>
7
+ <AbandonedCartsDashboard />
8
+ </PageLayout>
9
+
10
+ @endsection