@dev.smartpricing/message-composer-layer 1.0.12 → 1.0.14

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.
@@ -1,13 +1,10 @@
1
1
  export default defineNuxtRouteMiddleware((to) => {
2
2
  const { isChannelAllowed } = useChannels()
3
3
 
4
- // Extract channel from route path: /template/(whatsapp|email)/...
5
- const pathMatch = to.path.match(/\/template\/(whatsapp|email)/)
6
- if (!pathMatch) return
7
-
8
- const channel = pathMatch[1] as 'whatsapp' | 'email'
4
+ const channel = to.meta.channel as 'whatsapp' | 'email' | undefined
5
+ if (!channel) return
9
6
 
10
7
  if (!isChannelAllowed(channel)) {
11
- return navigateTo('/', { replace: true })
8
+ return navigateTo({ name: 'message-library' }, { replace: true })
12
9
  }
13
10
  })
@@ -1,191 +1,22 @@
1
1
  <script setup lang="ts">
2
- import type { BrandFromDb } from '@dev.smartpricing/message-composer-utils/types'
3
- import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
4
- import type { TableColumn } from '#ui/types'
5
- import { useBrandsQuery, useDeleteBrandMutation } from '~/queries'
6
- import BrandDrawer from '~/components/Brand/BrandDrawer.vue'
7
-
8
2
  definePageMeta({
9
3
  name: 'brands',
10
4
  })
11
5
 
12
6
  const { t } = useI18n()
13
- const { confirm } = useConfirm()
14
-
15
- const { data: brands, isPending } = useBrandsQuery()
16
- const { mutateAsync: deleteBrand } = useDeleteBrandMutation()
17
-
18
- // Drawer state
19
- const drawerOpen = ref(false)
20
- const editingBrandId = ref<string | undefined>(undefined)
21
-
22
- // Search
23
- const search = ref('')
24
- const filteredBrands = computed(() => {
25
- if (!brands.value?.length) return []
26
- if (!search.value.trim()) return brands.value
27
- const query = search.value.toLowerCase()
28
- return brands.value.filter((b) => b.name.toLowerCase().includes(query))
29
- })
30
-
31
- // Table config
32
- const columns: TableColumn<BrandFromDb>[] = [
33
- {
34
- id: 'logo',
35
- header: '',
36
- enableSorting: false,
37
- meta: {
38
- class: {
39
- th: 'w-12',
40
- td: 'w-12',
41
- },
42
- },
43
- },
44
- {
45
- accessorKey: 'name',
46
- header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_name')),
47
- },
48
- {
49
- accessorKey: 'created_at',
50
- header: (ctx) => getSortableHeader(ctx, t('pages.brands.column_created')),
51
- cell: ({ row }) => formatDate(row.original.created_at),
52
- },
53
- {
54
- id: 'actions',
55
- enableSorting: false,
56
- header: '',
57
- meta: {
58
- class: {
59
- th: 'w-[66px]',
60
- td: 'w-[66px]',
61
- },
62
- },
63
- },
64
- ]
65
-
66
- const columnPinning = ref({
67
- right: ['actions'],
68
- })
69
-
70
- const sorting = ref([
71
- {
72
- id: 'created_at',
73
- desc: true,
74
- },
75
- ])
76
-
77
- function getRowActions(brand: BrandFromDb) {
78
- return [
79
- {
80
- label: t('common.actions.edit'),
81
- icon: 'ph:pencil-simple-line',
82
- onSelect: () => openEdit(brand),
83
- },
84
- {
85
- label: t('common.actions.delete'),
86
- icon: 'ph:trash',
87
- color: 'error' as const,
88
- onSelect: () => askDelete(brand),
89
- },
90
- ]
91
- }
92
-
93
- function openCreate() {
94
- editingBrandId.value = undefined
95
- drawerOpen.value = true
96
- }
97
-
98
- function openEdit(brand: BrandFromDb) {
99
- editingBrandId.value = brand.id
100
- drawerOpen.value = true
101
- }
102
-
103
- function askDelete(brand: BrandFromDb) {
104
- confirm({
105
- title: t('pages.brands.delete_confirm_title'),
106
- message: t('pages.brands.delete_confirm_message', { name: brand.name }),
107
- destructive: true,
108
- confirmProps: {
109
- label: t('common.actions.delete'),
110
- },
111
- cancelProps: {
112
- label: t('common.actions.cancel'),
113
- },
114
- action: async () => {
115
- await deleteBrand(brand.id)
116
- useToast().add({
117
- color: 'success',
118
- title: t('common.success'),
119
- description: t('pages.brands.brand_deleted', { name: brand.name }),
120
- })
121
- },
122
- })
123
- }
124
-
125
- function handleDrawerSaved() {
126
- drawerOpen.value = false
127
- }
7
+ const brandListRef = ref<InstanceType<typeof ComposerBrandList>>()
128
8
  </script>
129
9
 
130
10
  <template>
131
11
  <LayoutBasePage :title="t('pages.brands.title')">
132
12
  <template #actions>
133
- <UButton icon="ph:plus" :label="t('pages.brands.new_brand')" @click="openCreate" />
13
+ <UButton
14
+ icon="ph:plus"
15
+ :label="t('pages.brands.new_brand')"
16
+ @click="brandListRef?.openCreate()"
17
+ />
134
18
  </template>
135
19
 
136
- <div class="flex-1 space-y-2.5 relative min-h-0 flex flex-col">
137
- <div class="flex gap-2">
138
- <UInput
139
- v-model="search"
140
- class="max-w-xs"
141
- leading-icon="ph:magnifying-glass-bold"
142
- :placeholder="t('pages.brands.search_placeholder')"
143
- type="search"
144
- />
145
- </div>
146
-
147
- <UCard
148
- :ui="{
149
- root: 'flex-1 min-h-0 flex flex-col',
150
- body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
151
- }"
152
- >
153
- <UTable
154
- v-model:column-pinning="columnPinning"
155
- v-model:sorting="sorting"
156
- :data="filteredBrands"
157
- :columns="columns"
158
- :loading="isPending"
159
- :empty="t('pages.brands.no_brands')"
160
- sticky
161
- class="flex-1 bg-white"
162
- :ui="{
163
- tr: 'hover:bg-muted',
164
- root: 'overflow-visible static',
165
- }"
166
- >
167
- <template #logo-cell="{ row }">
168
- <img
169
- v-if="row.original.logo_url"
170
- :src="row.original.logo_url"
171
- :alt="row.original.name"
172
- class="h-8 w-8 rounded-full object-cover border border-neutral-200"
173
- />
174
- <div
175
- v-else
176
- class="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center text-xs font-medium text-neutral-500"
177
- >
178
- {{ row.original.name.charAt(0).toUpperCase() }}
179
- </div>
180
- </template>
181
-
182
- <template #actions-cell="{ row }">
183
- <SMoreActions :actions="getRowActions(row.original)" />
184
- </template>
185
- </UTable>
186
- </UCard>
187
- </div>
188
-
189
- <BrandDrawer v-model="drawerOpen" :brand-id="editingBrandId" @saved="handleDrawerSaved" />
20
+ <ComposerBrandList ref="brandListRef" />
190
21
  </LayoutBasePage>
191
22
  </template>
@@ -1,137 +1,13 @@
1
1
  <script setup lang="ts">
2
- import type { MediaFromDb } from '@dev.smartpricing/message-composer-utils/types'
3
- import { useMediaQuery, useUploadToSmartchatMutation, useDeleteMediaMutation } from '~/queries'
4
- import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
2
+ definePageMeta({
3
+ name: 'images',
4
+ })
5
5
 
6
6
  const { t } = useI18n()
7
-
8
- const { data: media, isPending, refetch } = useMediaQuery()
9
- const { mutateAsync: uploadToSmartchat } = useUploadToSmartchatMutation()
10
- const { mutateAsync: deleteMedia } = useDeleteMediaMutation()
11
-
12
- const uploadingIds = ref<Set<string>>(new Set())
13
- const deletingIds = ref<Set<string>>(new Set())
14
-
15
- async function handleUploadToSmartchat(item: MediaFromDb) {
16
- uploadingIds.value.add(item.id)
17
- try {
18
- await uploadToSmartchat(item.id)
19
- useToast().add({
20
- color: 'success',
21
- title: t('common.success'),
22
- description: t('pages.images.image_synced'),
23
- })
24
- refetch()
25
- } finally {
26
- uploadingIds.value.delete(item.id)
27
- }
28
- }
29
-
30
- async function handleDelete(item: MediaFromDb) {
31
- if (!confirm(t('pages.images.delete_confirm', { name: item.file_name }))) return
32
-
33
- deletingIds.value.add(item.id)
34
- try {
35
- await deleteMedia(item.id)
36
- useToast().add({
37
- color: 'success',
38
- title: t('common.success'),
39
- description: t('pages.images.image_deleted'),
40
- })
41
- refetch()
42
- } finally {
43
- deletingIds.value.delete(item.id)
44
- }
45
- }
46
7
  </script>
47
8
 
48
9
  <template>
49
- <LayoutBasePage
50
- :title="t('pages.images.title')"
51
- :test-id="imagesPageTestIds.navbar"
52
- >
53
- <div v-if="isPending" class="flex justify-center py-12">
54
- <UIcon
55
- name="i-heroicons-arrow-path"
56
- class="animate-spin h-8 w-8"
57
- :data-testid="imagesPageTestIds.loadingIcon"
58
- />
59
- </div>
60
-
61
- <div
62
- v-else-if="!media || media.length === 0"
63
- class="text-center py-12 text-gray-500"
64
- :data-testid="imagesPageTestIds.emptyState"
65
- >
66
- {{ t('pages.images.no_images_uploaded') }}
67
- </div>
68
-
69
- <div
70
- v-else
71
- class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
72
- :data-testid="imagesPageTestIds.grid"
73
- >
74
- <UCard
75
- v-for="item in media"
76
- :key="item.id"
77
- :data-testid="`${imagesPageTestIds.card}-${item.id}`"
78
- >
79
- <div class="space-y-3">
80
- <img
81
- :src="item.remote_url"
82
- :alt="item.file_name"
83
- class="h-40 w-full object-cover rounded"
84
- :data-testid="`${imagesPageTestIds.image}-${item.id}`"
85
- />
86
-
87
- <div class="space-y-2">
88
- <div class="flex items-center justify-between">
89
- <span
90
- class="text-sm font-medium truncate"
91
- :title="item.file_name"
92
- :data-testid="`${imagesPageTestIds.fileName}-${item.id}`"
93
- >
94
- {{ item.file_name }}
95
- </span>
96
- <UBadge
97
- :color="item.smartchat_media_id ? 'success' : 'neutral'"
98
- variant="subtle"
99
- size="xs"
100
- :data-testid="`${imagesPageTestIds.syncBadge}-${item.id}`"
101
- >
102
- {{
103
- item.smartchat_media_id ? t('pages.images.synced') : t('pages.images.not_synced')
104
- }}
105
- </UBadge>
106
- </div>
107
-
108
- <div class="text-xs text-gray-500 space-y-1">
109
- <div>{{ t('pages.images.type_label') }} {{ item.file_type }}</div>
110
- <div>{{ t('pages.images.created_label') }} {{ formatDate(item.created_at) }}</div>
111
- <div
112
- v-if="item.smartchat_media_id"
113
- class="font-mono truncate"
114
- :title="item.smartchat_media_id"
115
- >
116
- {{ t('pages.images.media_id_label') }} {{ item.smartchat_media_id }}
117
- </div>
118
- </div>
119
-
120
- <div v-if="!item.smartchat_media_id" class="flex gap-2 pt-2">
121
- <UButton
122
- size="xs"
123
- color="primary"
124
- block
125
- :loading="uploadingIds.has(item.id)"
126
- :data-testid="`${imagesPageTestIds.syncButton}-${item.id}`"
127
- @click="handleUploadToSmartchat(item)"
128
- >
129
- {{ t('pages.images.sync_to_smartchat') }}
130
- </UButton>
131
- </div>
132
- </div>
133
- </div>
134
- </UCard>
135
- </div>
10
+ <LayoutBasePage :title="t('pages.images.title')" :test-id="imagesPageTestIds.navbar">
11
+ <ComposerImageGallery />
136
12
  </LayoutBasePage>
137
13
  </template>
@@ -3,26 +3,15 @@ definePageMeta({
3
3
  name: 'message-library',
4
4
  })
5
5
 
6
- import {
7
- useMessageGroupsQuery,
8
- useDeleteMessageGroupMutation,
9
- usePublishMessageGroupMutation,
10
- useUnpublishMessageGroupMutation,
11
- } from '~/queries'
12
- import type {
13
- MessageGroupWithMessageSummary,
14
- RenderType,
15
- } from '@dev.smartpricing/message-composer-utils/types'
16
- import type { MessageActionProperties, MessagesKind } from '@/types/tracking'
17
- import { ref, watch, computed } from 'vue'
18
- import { useRouter } from 'vue-router'
19
- import BaseTable from '@/components/TemplateList/BaseTable.vue'
6
+ import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
20
7
  import CreateTemplateButton from '@/components/CreateTemplateButton.vue'
8
+ import { ref, watch, computed } from 'vue'
21
9
 
22
10
  const { hasWhatsapp, hasEmail, defaultRenderType, isRenderTypeAllowed } = useChannels()
23
11
  const { trackEvent } = useTracking()
12
+ const { t } = useI18n()
24
13
 
25
- // Load render type from session or default to first allowed
14
+ // Render type with session persistence
26
15
  const RENDER_TYPE_KEY = 'template_render_type'
27
16
  const storedRenderType = sessionStorage.getItem(RENDER_TYPE_KEY) as RenderType | null
28
17
  const initialRenderType =
@@ -31,122 +20,10 @@ const initialRenderType =
31
20
  : defaultRenderType.value
32
21
  const renderType = ref<RenderType>(initialRenderType)
33
22
 
34
- // Persist render type to session when it changes
35
23
  watch(renderType, (newType) => {
36
24
  sessionStorage.setItem(RENDER_TYPE_KEY, newType)
37
25
  trackEvent('change_tab', { messages_kind: newType === 'email_v1' ? 'email' : 'whatsapp' })
38
26
  })
39
- const {
40
- data: messageGroups,
41
- isPending: isFetching,
42
- refetch: execute,
43
- } = useMessageGroupsQuery(computed(() => ({ renderType: renderType.value })))
44
-
45
- // Manual polling (Pinia Colada doesn't support automatic polling yet)
46
- const interval = setInterval(() => {
47
- execute()
48
- }, 15000)
49
-
50
- onBeforeUnmount(() => {
51
- clearInterval(interval)
52
- })
53
-
54
- const { confirm } = useConfirm()
55
- const { mutateAsync: deleteGroup } = useDeleteMessageGroupMutation()
56
- const { mutateAsync: publishGroup } = usePublishMessageGroupMutation()
57
- const { mutateAsync: unpublishGroup } = useUnpublishMessageGroupMutation()
58
-
59
- function getMessageTrackingProps(row: MessageGroupWithMessageSummary): MessageActionProperties {
60
- return {
61
- message_name: row.name,
62
- languages: row.messages.map((m) => m.language),
63
- message_id: row.id,
64
- message_kind: (row.messages[0]?.render_type === 'email_v1'
65
- ? 'email'
66
- : 'whatsapp') as MessagesKind,
67
- message_category: row.category,
68
- }
69
- }
70
-
71
- function askDeleteGroup(row: MessageGroupWithMessageSummary) {
72
- confirm({
73
- title: t('dialogs.delete_message_group.title'),
74
- message: t('dialogs.delete_message_group.message', { name: row.name }),
75
- destructive: true,
76
- confirmLabel: t('common.actions.delete'),
77
- cancelLabel: t('common.actions.cancel'),
78
- action: async () => {
79
- await deleteGroup(row.id)
80
- trackEvent('message_delete', getMessageTrackingProps(row))
81
- notifyParent('TEMPLATE_DELETED', { id: row.id, name: row.name })
82
- useToast().add({
83
- title: t('common.success'),
84
- description: t('dialogs.delete_message_group.success', { name: row.name }),
85
- color: 'success',
86
- })
87
- execute()
88
- },
89
- })
90
- }
91
-
92
- function askPublishGroup(row: MessageGroupWithMessageSummary) {
93
- confirm({
94
- title: t('dialogs.publish_message_group.title'),
95
- message: t('dialogs.publish_message_group.message', { name: row.name }),
96
- confirmLabel: t('common.actions.publish'),
97
- cancelLabel: t('common.actions.cancel'),
98
- action: async () => {
99
- await publishGroup(row.id)
100
- notifyParent('TEMPLATE_PUBLISHED', { id: row.id, name: row.name })
101
- useToast().add({
102
- title: t('common.success'),
103
- description: t('dialogs.publish_message_group.success', { name: row.name }),
104
- color: 'success',
105
- })
106
- execute()
107
- },
108
- })
109
- }
110
-
111
- function askUnpublishGroup(row: MessageGroupWithMessageSummary) {
112
- confirm({
113
- title: t('dialogs.unpublish_message_group.title'),
114
- message: t('dialogs.unpublish_message_group.message', { name: row.name }),
115
- confirmLabel: t('common.actions.unpublish'),
116
- cancelLabel: t('common.actions.cancel'),
117
- action: async () => {
118
- await unpublishGroup(row.id)
119
- notifyParent('TEMPLATE_UNPUBLISHED', { id: row.id, name: row.name })
120
- useToast().add({
121
- title: t('common.success'),
122
- description: t('dialogs.unpublish_message_group.success', { name: row.name }),
123
- color: 'success',
124
- })
125
- execute()
126
- },
127
- })
128
- }
129
-
130
- const { duplicateTemplate } = useTemplateOperations({
131
- onSuccess: () => execute(), // Refresh the list
132
- })
133
-
134
- async function handleDuplicate(row: MessageGroupWithMessageSummary) {
135
- trackEvent('message_duplicate', getMessageTrackingProps(row))
136
- const result = await duplicateTemplate(row.id.toString(), t('common.copy_of', { name: row.name }))
137
- if (result) {
138
- notifyParent('TEMPLATE_DUPLICATED', { id: result.id, sourceId: row.id, name: result.name })
139
- }
140
- }
141
-
142
- const router = useRouter()
143
- function navigate(row: MessageGroupWithMessageSummary) {
144
- trackEvent('message_edit', getMessageTrackingProps(row))
145
- const type = row.messages[0]?.render_type === 'email_v1' ? 'email' : 'whatsapp'
146
- router.push(`/template/${type}/${row.id}`)
147
- }
148
-
149
- const { t } = useI18n()
150
27
 
151
28
  const tabItems = computed(() => {
152
29
  const items = []
@@ -174,21 +51,12 @@ const tabItems = computed(() => {
174
51
  :tabs="tabItems.length > 1 ? tabItems : undefined"
175
52
  :active-tab="renderType"
176
53
  :test-id="homeTestIds.navbar"
177
- @tab-change="renderType = $event"
54
+ @tab-change="renderType = $event as RenderType"
178
55
  >
179
56
  <template #actions>
180
57
  <CreateTemplateButton />
181
58
  </template>
182
59
 
183
- <BaseTable
184
- :loading="isFetching"
185
- :message-groups="messageGroups || []"
186
- :render-type="renderType"
187
- @edit="navigate"
188
- @delete="askDeleteGroup"
189
- @duplicate="handleDuplicate"
190
- @publish="askPublishGroup"
191
- @unpublish="askUnpublishGroup"
192
- />
60
+ <ComposerMessageLibrary v-model:render-type="renderType" />
193
61
  </LayoutBasePage>
194
62
  </template>
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
6
6
  import { useMessageGroupQuery } from '~/queries/messageGroups'
7
7
 
8
8
  definePageMeta({
9
+ name: 'message-preview',
9
10
  layout: false,
10
11
  })
11
12