@stacksjs/defaults 0.70.357 → 0.70.362

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 (55) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +10 -5
  2. package/ai/skills/stacks-browse/scripts/browse.ts +109 -13
  3. package/app/Actions/AI/AskAction.ts +12 -17
  4. package/app/Actions/AI/SummaryAction.ts +12 -17
  5. package/app/Actions/Dashboard/Actions/GetActions.ts +15 -9
  6. package/app/Actions/Dashboard/Analytics/EventStoreAction.ts +21 -9
  7. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +12 -3
  8. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +12 -3
  9. package/app/Actions/Dashboard/DashboardHealthAction.ts +34 -14
  10. package/app/Actions/Dashboard/DashboardHomeAction.test.ts +1 -1
  11. package/app/Actions/Dashboard/DashboardHomeAction.ts +31 -4
  12. package/app/Actions/Dashboard/DashboardStatsAction.ts +6 -1
  13. package/app/Actions/Dashboard/Email/InboxSendAction.ts +14 -7
  14. package/app/Actions/Dashboard/Infrastructure/CommandIndexAction.ts +33 -27
  15. package/app/Actions/Dashboard/Infrastructure/EnvironmentIndexAction.ts +10 -4
  16. package/app/Actions/Dashboard/Infrastructure/EnvironmentUpdateAction.ts +8 -1
  17. package/app/Actions/Dashboard/Infrastructure/RequestIndexAction.ts +24 -18
  18. package/app/Actions/Dashboard/Infrastructure/ServerIndexAction.ts +15 -9
  19. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +9 -2
  20. package/app/Actions/Dashboard/Marketing/CampaignCancelAction.ts +23 -5
  21. package/app/Actions/Dashboard/Marketing/CampaignDestroyAction.ts +26 -17
  22. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +49 -43
  23. package/app/Actions/Dashboard/Marketing/CampaignScheduleAction.ts +31 -25
  24. package/app/Actions/Dashboard/Marketing/CampaignSendAction.ts +28 -22
  25. package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +15 -10
  26. package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +19 -12
  27. package/app/Actions/Dashboard/Marketing/ListDestroyAction.ts +34 -25
  28. package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +32 -26
  29. package/app/Actions/Dashboard/Marketing/ListStoreAction.ts +31 -18
  30. package/app/Actions/Dashboard/Marketing/ListUpdateAction.ts +35 -19
  31. package/app/Actions/Dashboard/Marketing/SocialPostDestroyAction.ts +15 -5
  32. package/app/Actions/Dashboard/Marketing/SocialPostIndexAction.ts +11 -5
  33. package/app/Actions/Dashboard/Marketing/SocialPostStoreAction.ts +19 -13
  34. package/app/Actions/Dashboard/Marketing/SocialPostUpdateAction.ts +22 -12
  35. package/app/Actions/Dashboard/Marketing/campaign-delivery.ts +34 -0
  36. package/app/Actions/Dashboard/Marketing/marketing-list-records.test.ts +10 -0
  37. package/app/Actions/Dashboard/Marketing/marketing-list-records.ts +10 -0
  38. package/app/Actions/Dashboard/Marketing/marketing-response.ts +28 -0
  39. package/app/Actions/Dashboard/Marketing/social-post-records.test.ts +13 -0
  40. package/app/Actions/Dashboard/Marketing/social-post-records.ts +22 -4
  41. package/app/Actions/Dashboard/Models/GetModels.ts +14 -7
  42. package/app/Actions/Dashboard/Models/GetSubscriberCount.ts +8 -1
  43. package/app/Actions/Dashboard/Models/GetUserCount.ts +8 -1
  44. package/app/Actions/Dashboard/Releases/ReleaseIndexAction.ts +11 -5
  45. package/app/Actions/Dashboard/Settings/MailSettingsGetAction.ts +10 -4
  46. package/app/Actions/Dashboard/Settings/MailSettingsUpdateAction.ts +8 -1
  47. package/app/Actions/LogAction.ts +11 -11
  48. package/ide/vscode/package.json +1 -1
  49. package/package.json +1 -1
  50. package/resources/components/Dashboard/Marketing/CampaignDeleteDialog.stx +11 -12
  51. package/resources/components/Dashboard/Marketing/MarketingListDeleteDialog.stx +15 -15
  52. package/resources/components/Dashboard/Marketing/SocialPostDeleteDialog.stx +11 -12
  53. package/resources/components/Dashboard/UI/ConfirmDialog.stx +51 -53
  54. package/resources/components/Dashboard/UI/Modal.stx +83 -29
  55. package/views/dashboard/stores/auth.ts +18 -2
@@ -2,7 +2,8 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { SocialPost } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
- import { socialPostWriteData, validateSocialPostSchedule } from './social-post-records'
5
+ import { marketingModelError, marketingRecordId } from './marketing-response'
6
+ import { socialPostWriteData, validateSocialPostWriteData } from './social-post-records'
6
7
 
7
8
  export default new Action({
8
9
  name: 'SocialPostUpdateAction',
@@ -11,18 +12,27 @@ export default new Action({
11
12
  model: SocialPost,
12
13
 
13
14
  async handle(request: RequestInstance) {
14
- await request.validate()
15
- const id = Number(request.getParam('id'))
16
- const post = await SocialPost.find(id)
17
- if (!post)
18
- return response.json({ message: 'Social post not found.' }, 404)
19
-
15
+ const id = marketingRecordId(request)
16
+ if (!id)
17
+ return response.json({ message: 'A valid social post id is required.' }, 400)
20
18
  const data = socialPostWriteData(await request.all())
21
- const scheduleError = validateSocialPostSchedule(data)
22
- if (scheduleError)
23
- return response.json({ message: scheduleError }, 422)
19
+ const validationError = validateSocialPostWriteData(data)
20
+ if (validationError)
21
+ return response.json({ message: validationError }, 422)
22
+
23
+ try {
24
+ const post = await SocialPost.find(id)
25
+ if (!post)
26
+ return response.json({ message: 'Social post not found.' }, 404)
24
27
 
25
- await post.update(data)
26
- return response.json({ id })
28
+ await post.update({
29
+ ...data,
30
+ platform: data.platform as Exclude<typeof data.platform, ''>,
31
+ })
32
+ return response.json({ id })
33
+ }
34
+ catch (error) {
35
+ return marketingModelError(error, 'Social post could not be updated.', 'SocialPostUpdateAction')
36
+ }
27
37
  },
28
38
  })
@@ -1,5 +1,39 @@
1
+ import { db } from '@stacksjs/database'
2
+ import { Campaign, EmailList } from '@stacksjs/orm'
3
+
1
4
  export type CampaignDeliveryOperation = 'send' | 'schedule' | 'cancel'
2
5
 
6
+ export interface CampaignDeliveryContext {
7
+ campaign: any | null
8
+ activeMembers: number
9
+ listStatus: string
10
+ }
11
+
12
+ export async function loadCampaignDeliveryContext(id: number): Promise<CampaignDeliveryContext> {
13
+ const campaign = await Campaign.find(id)
14
+ if (!campaign)
15
+ return { campaign: null, activeMembers: 0, listStatus: '' }
16
+
17
+ const listId = Number(campaign.get('email_list_id') || 0)
18
+ const [list, memberRow] = listId
19
+ ? await Promise.all([
20
+ EmailList.find(listId),
21
+ db
22
+ .selectFrom('email_list_subscribers')
23
+ .select(db.fn.count('id').as('count'))
24
+ .where('email_list_id', '=', listId)
25
+ .where('status', '=', 'subscribed')
26
+ .executeTakeFirst(),
27
+ ])
28
+ : [null, null]
29
+
30
+ return {
31
+ campaign,
32
+ activeMembers: Number(memberRow?.count || 0),
33
+ listStatus: String(list?.get('status') || ''),
34
+ }
35
+ }
36
+
3
37
  function value(record: any, ...keys: string[]): unknown {
4
38
  for (const key of keys) {
5
39
  const result = typeof record?.get === 'function' ? record.get(key) : record?.[key]
@@ -3,6 +3,7 @@ import {
3
3
  marketingListWriteData,
4
4
  normalizeMarketingLists,
5
5
  slugifyMarketingList,
6
+ validateMarketingListWriteData,
6
7
  } from './marketing-list-records'
7
8
 
8
9
  describe('marketing list records', () => {
@@ -68,4 +69,13 @@ describe('marketing list records', () => {
68
69
  doubleOptIn: 0,
69
70
  })
70
71
  })
72
+
73
+ test('rejects empty and oversized dashboard writes before model defaults apply', () => {
74
+ expect(validateMarketingListWriteData(marketingListWriteData({})))
75
+ .toBe('List name must be between 2 and 100 characters.')
76
+ expect(validateMarketingListWriteData(marketingListWriteData({
77
+ name: 'Product News',
78
+ description: 'x'.repeat(501),
79
+ }))).toBe('List description must be 500 characters or fewer.')
80
+ })
71
81
  })
@@ -159,3 +159,13 @@ export function marketingListWriteData(input: Record<string, unknown>): {
159
159
  doubleOptIn: boolean(input.doubleOptIn ?? input.double_opt_in) ? 1 : 0,
160
160
  }
161
161
  }
162
+
163
+ export function validateMarketingListWriteData(data: ReturnType<typeof marketingListWriteData>): string {
164
+ if (data.name.length < 2 || data.name.length > 100)
165
+ return 'List name must be between 2 and 100 characters.'
166
+ if (!data.slug || data.slug.length > 120)
167
+ return 'List slug must be between 1 and 120 characters.'
168
+ if (data.description.length > 500)
169
+ return 'List description must be 500 characters or fewer.'
170
+ return ''
171
+ }
@@ -0,0 +1,28 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { isUniqueViolation, ModelValidationError } from '@stacksjs/orm'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
+
6
+ export function marketingRecordId(request: RequestInstance): number | null {
7
+ const id = Number(request.getParam('id'))
8
+ return Number.isSafeInteger(id) && id > 0 ? id : null
9
+ }
10
+
11
+ export function marketingModelError(
12
+ error: unknown,
13
+ message: string,
14
+ action: string,
15
+ duplicateMessage?: string,
16
+ ): Response {
17
+ if (error instanceof ModelValidationError) {
18
+ return response.json({
19
+ message: 'Validation failed.',
20
+ errors: error.errors,
21
+ }, 422)
22
+ }
23
+
24
+ if (duplicateMessage && isUniqueViolation(error))
25
+ return response.json({ message: duplicateMessage }, 422)
26
+
27
+ return dashboardOperationalError(error, message, action, 500)
28
+ }
@@ -3,6 +3,7 @@ import {
3
3
  normalizeSocialPosts,
4
4
  socialPostWriteData,
5
5
  validateSocialPostSchedule,
6
+ validateSocialPostWriteData,
6
7
  } from './social-post-records'
7
8
 
8
9
  const now = new Date('2026-07-29T12:00:00.000Z')
@@ -68,4 +69,16 @@ describe('social post records', () => {
68
69
  }, now)
69
70
  expect(validateSocialPostSchedule(stale, now)).toBe('Schedule time must be in the future.')
70
71
  })
72
+
73
+ test('rejects missing content, invalid platforms, and unsafe image URLs', () => {
74
+ expect(validateSocialPostWriteData(socialPostWriteData({})))
75
+ .toBe('Post content must be between 1 and 2000 characters.')
76
+ expect(validateSocialPostWriteData(socialPostWriteData({ content: 'Ready to publish' })))
77
+ .toBe('Choose a valid social platform.')
78
+ expect(validateSocialPostWriteData(socialPostWriteData({
79
+ content: 'Ready to publish',
80
+ platform: 'linkedin',
81
+ imageUrl: 'javascript:alert(1)',
82
+ }))).toBe('Image URL must use HTTP or HTTPS.')
83
+ })
71
84
  })
@@ -55,11 +55,11 @@ function number(input: unknown): number {
55
55
  return Number.isFinite(result) && result >= 0 ? result : 0
56
56
  }
57
57
 
58
- function platform(input: unknown): SocialPostPlatform {
58
+ function platform(input: unknown): SocialPostPlatform | '' {
59
59
  const normalized = text(input).toLowerCase()
60
60
  return ['twitter', 'facebook', 'instagram', 'linkedin', 'tiktok', 'youtube'].includes(normalized)
61
61
  ? normalized as SocialPostPlatform
62
- : 'twitter'
62
+ : ''
63
63
  }
64
64
 
65
65
  function status(input: unknown): SocialPostStatus {
@@ -89,7 +89,7 @@ export function normalizeSocialPosts(
89
89
  return {
90
90
  id: text(value(post, 'id')),
91
91
  content: text(value(post, 'content')),
92
- platform: platform(value(post, 'platform')),
92
+ platform: platform(value(post, 'platform')) || 'twitter',
93
93
  status: status(value(post, 'status')),
94
94
  scheduledAt: text(value(post, 'scheduled_at', 'scheduledAt')),
95
95
  publishedAt: text(value(post, 'published_at', 'publishedAt')),
@@ -124,7 +124,7 @@ export function normalizeSocialPosts(
124
124
 
125
125
  export function socialPostWriteData(input: Record<string, unknown>, now = new Date()): {
126
126
  content: string
127
- platform: SocialPostPlatform
127
+ platform: SocialPostPlatform | ''
128
128
  status: SocialPostStatus
129
129
  scheduled_at: string | null
130
130
  published_at: string | null
@@ -161,3 +161,21 @@ export function validateSocialPostSchedule(data: ReturnType<typeof socialPostWri
161
161
  return 'Schedule time must be in the future.'
162
162
  return ''
163
163
  }
164
+
165
+ export function validateSocialPostWriteData(data: ReturnType<typeof socialPostWriteData>): string {
166
+ if (data.content.length < 1 || data.content.length > 2000)
167
+ return 'Post content must be between 1 and 2000 characters.'
168
+ if (!data.platform)
169
+ return 'Choose a valid social platform.'
170
+ if (data.image_url) {
171
+ try {
172
+ const url = new URL(data.image_url)
173
+ if (!['http:', 'https:'].includes(url.protocol))
174
+ return 'Image URL must use HTTP or HTTPS.'
175
+ }
176
+ catch {
177
+ return 'Image URL must be a valid URL.'
178
+ }
179
+ }
180
+ return validateSocialPostSchedule(data)
181
+ }
@@ -3,15 +3,18 @@ import { Glob } from 'bun'
3
3
  import { Action } from '@stacksjs/actions'
4
4
  import { path } from '@stacksjs/path'
5
5
  import { countRows } from '../../../../resources/functions/dashboard/data'
6
+ import { dashboardOperationalError } from '../dashboard-response'
6
7
 
7
8
  export default new Action({
8
9
  name: 'GetModels',
9
10
  description: 'Gets the application models.',
11
+ method: 'GET',
10
12
 
11
13
  async handle() {
12
- const glob = new Glob('**/*.ts')
13
- const scanOptions = { cwd: path.userModelsPath(), onlyFiles: true }
14
- const displayModels: Model[] = []
14
+ try {
15
+ const glob = new Glob('**/*.ts')
16
+ const scanOptions = { cwd: path.userModelsPath(), onlyFiles: true }
17
+ const displayModels: Model[] = []
15
18
 
16
19
  for await (const file of glob.scan(scanOptions)) {
17
20
  const model = (await import(path.userModelsPath(file))).default as Model
@@ -31,9 +34,13 @@ export default new Action({
31
34
  displayModels.length = 8
32
35
  displayModels.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
33
36
 
34
- return await Promise.all(displayModels.map(async model => ({
35
- model: model.name,
36
- total: await countRows(model),
37
- })))
37
+ return await Promise.all(displayModels.map(async model => ({
38
+ model: model.name,
39
+ total: await countRows(model),
40
+ })))
41
+ }
42
+ catch (error) {
43
+ return dashboardOperationalError(error, 'Dashboard models could not be loaded.', 'GetModels')
44
+ }
38
45
  },
39
46
  })
@@ -1,12 +1,19 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Subscriber } from '@stacksjs/orm'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  export default new Action({
5
6
  name: 'GetSubscriberCount',
6
7
  description: 'Gets the total number of subscribers.',
8
+ method: 'GET',
7
9
  apiResponse: true,
8
10
 
9
11
  async handle() {
10
- return await Subscriber.count()
12
+ try {
13
+ return await Subscriber.count()
14
+ }
15
+ catch (error) {
16
+ return dashboardOperationalError(error, 'Subscriber total could not be loaded.', 'GetSubscriberCount')
17
+ }
11
18
  },
12
19
  })
@@ -1,12 +1,19 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { User } from '@stacksjs/orm'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  export default new Action({
5
6
  name: 'GetUserCount',
6
7
  description: 'Gets the total number of users.',
8
+ method: 'GET',
7
9
  apiResponse: true,
8
10
 
9
11
  async handle() {
10
- return User.count()
12
+ try {
13
+ return await User.count()
14
+ }
15
+ catch (error) {
16
+ return dashboardOperationalError(error, 'User total could not be loaded.', 'GetUserCount')
17
+ }
11
18
  },
12
19
  })
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Release } from '@stacksjs/orm'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
  import { normalizeReleaseRecord, summarizeReleases } from './release-records'
4
5
 
5
6
  export default new Action({
@@ -9,12 +10,17 @@ export default new Action({
9
10
  apiResponse: true,
10
11
 
11
12
  async handle() {
12
- const records = await Release.orderByDesc('id').limit(200).get()
13
- const releases = records.map(normalizeReleaseRecord)
13
+ try {
14
+ const records = await Release.orderByDesc('id').limit(200).get()
15
+ const releases = records.map(normalizeReleaseRecord)
14
16
 
15
- return {
16
- releases,
17
- summary: summarizeReleases(releases),
17
+ return {
18
+ releases,
19
+ summary: summarizeReleases(releases),
20
+ }
21
+ }
22
+ catch (error) {
23
+ return dashboardOperationalError(error, 'Release history could not be loaded.', 'ReleaseIndexAction')
18
24
  }
19
25
  },
20
26
  })
@@ -1,4 +1,5 @@
1
1
  import { Action } from '@stacksjs/actions'
2
+ import { dashboardOperationalError } from '../dashboard-response'
2
3
  import { readMailSettings } from './mail-settings'
3
4
 
4
5
  export default new Action({
@@ -8,9 +9,14 @@ export default new Action({
8
9
  apiResponse: true,
9
10
 
10
11
  async handle() {
11
- return Response.json(
12
- { settings: await readMailSettings() },
13
- { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } },
14
- )
12
+ try {
13
+ return Response.json(
14
+ { settings: await readMailSettings() },
15
+ { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } },
16
+ )
17
+ }
18
+ catch (error) {
19
+ return dashboardOperationalError(error, 'Mail settings could not be loaded.', 'MailSettingsGetAction')
20
+ }
15
21
  },
16
22
  })
@@ -1,6 +1,7 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
4
5
  import { updateMailSettings } from './mail-settings'
5
6
 
6
7
  export default new Action({
@@ -10,7 +11,13 @@ export default new Action({
10
11
  apiResponse: true,
11
12
 
12
13
  async handle(request: RequestInstance) {
13
- const result = await updateMailSettings(await request.all())
14
+ let result
15
+ try {
16
+ result = await updateMailSettings(await request.all())
17
+ }
18
+ catch (error) {
19
+ return dashboardOperationalError(error, 'Mail settings could not be saved.', 'MailSettingsUpdateAction', 500)
20
+ }
14
21
 
15
22
  if ('validation' in result) {
16
23
  return response.json({
@@ -1,15 +1,16 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { log } from '@stacksjs/logging'
4
+ import { response } from '@stacksjs/router'
3
5
  import { schema } from '@stacksjs/validation'
4
6
 
5
- interface Request {
6
- message: string
7
- level: 'info' | 'warn' | 'error'
8
- }
7
+ type LogLevel = 'info' | 'warn' | 'error'
9
8
 
10
9
  export default new Action({
11
10
  name: 'Dummy Logger',
12
11
  description: 'This action is used to demo how to POST to a server and upon success, log a message.',
12
+ method: 'POST',
13
+ apiResponse: true,
13
14
 
14
15
  // the request object is optional, but if it is provided, it will be used for validation
15
16
  validations: {
@@ -29,14 +30,13 @@ export default new Action({
29
30
  },
30
31
  },
31
32
 
32
- // handle(request: { message: string, level: 'info' | 'warn' | 'error' }) {
33
- handle(request?: Request) {
34
- if (!request)
35
- return 'No request was provided.'
33
+ async handle(request: RequestInstance) {
34
+ await request.validate()
36
35
 
37
- // TODO: need to validate with ts-validation
38
- log[request.level](request.message)
36
+ const message = String(request.get('message') || '').trim()
37
+ const level = String(request.get('level')) as LogLevel
38
+ log[level](message)
39
39
 
40
- return `Logged "${request.message}" at "${request.level}" level`
40
+ return response.json({ level, message: `Logged at ${level} level.` })
41
41
  },
42
42
  })
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.70.357",
5
+ "version": "0.70.362",
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.70.357",
5
+ "version": "0.70.362",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -6,17 +6,16 @@ const record = useReactiveProp('record', null as CampaignRecord | null)
6
6
  const busy = useReactiveProp('busy', false)
7
7
  const error = useReactiveProp('error', '')
8
8
  const emit = defineEmits()
9
+ const description = derived(() => `The persisted campaign ${record()?.name || ''} will be permanently removed. Campaigns with delivery history are retained.`)
9
10
  </script>
10
11
 
11
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
12
- <button type="button" aria-label="Close delete campaign dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
13
- <section role="dialog" aria-modal="true" aria-labelledby="campaign-delete-title" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
14
- <h2 id="campaign-delete-title" class="font-semibold text-gray-900 text-lg dark:text-white">Delete campaign?</h2>
15
- <p class="mt-2 text-gray-500 text-sm dark:text-neutral-400">The persisted campaign <strong>{{ record()?.name || '' }}</strong> will be permanently removed. Campaigns with delivery history are retained.</p>
16
- <div :if="error()" role="alert" class="mt-4 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>
17
- <div class="flex gap-2 justify-end mt-6">
18
- <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
19
- <Button :loading="busy()" variant="danger" @click="emit('confirm')">Delete campaign</Button>
20
- </div>
21
- </section>
22
- </div>
12
+ <ConfirmDialog
13
+ :isOpen="open()"
14
+ title="Delete campaign?"
15
+ :description="description()"
16
+ confirmLabel="Delete campaign"
17
+ :busy="busy()"
18
+ :error="error()"
19
+ @close="emit('close')"
20
+ @confirm="emit('confirm')"
21
+ />
@@ -6,20 +6,20 @@ const record = useReactiveProp('record', null as MarketingListRecord | null)
6
6
  const busy = useReactiveProp('busy', false)
7
7
  const error = useReactiveProp('error', '')
8
8
  const emit = defineEmits()
9
+ const protectedRecord = derived(() => Boolean(record()?.subscriberCount) || Boolean(record()?.campaignCount))
10
+ const description = derived(() => protectedRecord()
11
+ ? `${record()?.name || 'This email list'} has membership or campaign history. Archive it instead to preserve relationships.`
12
+ : `${record()?.name || 'This email list'} will be permanently removed.`)
9
13
  </script>
10
14
 
11
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
12
- <button type="button" aria-label="Close delete email list dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
13
- <section role="dialog" aria-modal="true" aria-labelledby="marketing-list-delete-title" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
14
- <h2 id="marketing-list-delete-title" class="font-semibold text-gray-900 text-lg dark:text-white">Delete email list?</h2>
15
- <p class="mt-2 text-gray-600 text-sm dark:text-neutral-300"><span class="font-medium">{{ record()?.name }}</span> will be permanently removed.</p>
16
- <p :if="record()?.subscriberCount || record()?.campaignCount" class="mt-3 p-3 text-amber-800 text-sm dark:text-amber-200 bg-amber-50 dark:bg-amber-950/30 rounded-lg">This list has membership or campaign history. Archive it instead to preserve relationships.</p>
17
- <p :if="error()" role="alert" class="mt-3 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 rounded-lg">{{ error() }}</p>
18
- <footer class="flex gap-2 justify-end mt-6">
19
- <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
20
- <Button :disabled="busy() || Boolean(record()?.subscriberCount) || Boolean(record()?.campaignCount)" :loading="busy()" variant="danger" @click="emit('confirm')">
21
- Delete email list
22
- </Button>
23
- </footer>
24
- </section>
25
- </div>
15
+ <ConfirmDialog
16
+ :isOpen="open()"
17
+ title="Delete email list?"
18
+ :description="description()"
19
+ confirmLabel="Delete email list"
20
+ :busy="busy()"
21
+ :disabled="protectedRecord()"
22
+ :error="error()"
23
+ @close="emit('close')"
24
+ @confirm="emit('confirm')"
25
+ />
@@ -6,17 +6,16 @@ const record = useReactiveProp('record', null as SocialPostRecord | null)
6
6
  const busy = useReactiveProp('busy', false)
7
7
  const error = useReactiveProp('error', '')
8
8
  const emit = defineEmits()
9
+ const description = derived(() => `The persisted ${record()?.platform || 'social'} post will be permanently removed. This does not remove a post already published on the external platform.`)
9
10
  </script>
10
11
 
11
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
12
- <button type="button" aria-label="Close delete social post dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
13
- <section role="dialog" aria-modal="true" aria-labelledby="social-post-delete-title" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
14
- <h2 id="social-post-delete-title" class="font-semibold text-gray-900 text-lg dark:text-white">Delete social post?</h2>
15
- <p class="mt-2 text-gray-600 text-sm dark:text-neutral-300">The persisted {{ record()?.platform }} post will be permanently removed. This does not remove a post already published on the external platform.</p>
16
- <p :if="error()" role="alert" class="mt-3 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 rounded-lg">{{ error() }}</p>
17
- <footer class="flex gap-2 justify-end mt-6">
18
- <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
19
- <Button :loading="busy()" variant="danger" @click="emit('confirm')">Delete social post</Button>
20
- </footer>
21
- </section>
22
- </div>
12
+ <ConfirmDialog
13
+ :isOpen="open()"
14
+ title="Delete social post?"
15
+ :description="description()"
16
+ confirmLabel="Delete social post"
17
+ :busy="busy()"
18
+ :error="error()"
19
+ @close="emit('close')"
20
+ @confirm="emit('confirm')"
21
+ />