@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.4.1-next.0
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.
- package/README.md +184 -48
- package/app/api/client.ts +12 -0
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +5 -2
- package/app/api/messageGroups.ts +23 -12
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +153 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/MessageSendFlow/EmailPreview.vue +78 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +252 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/TemplateList/BaseTable.vue +156 -108
- package/app/components/Whatsapp/Preview.vue +12 -4
- package/app/components/Whatsapp/PreviewPanel.vue +7 -1
- package/app/composables/composerContext.ts +4 -2
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageSendFlow.ts +246 -0
- package/app/composables/usePagedMessageGroups.ts +66 -0
- package/app/composables/useTemplateOperations.ts +13 -3
- package/app/layouts/default.vue +1 -0
- package/app/queries/compilation.ts +1 -1
- package/app/queries/dynamicValues.ts +8 -2
- package/app/queries/messageGroups.ts +29 -16
- package/app/stores/appContext.ts +12 -6
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +56 -2
- package/i18n/locales/en.ts +53 -2
- package/i18n/locales/es.ts +54 -2
- package/i18n/locales/fr.ts +53 -2
- package/i18n/locales/it.ts +55 -2
- package/package.json +2 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_GROUP_CATEGORIES,
|
|
4
|
+
MESSAGE_GROUP_LIST_SORT,
|
|
5
|
+
MESSAGE_GROUP_LIST_VISIBILITY,
|
|
6
|
+
MESSAGE_GROUP_PRODUCTS,
|
|
7
|
+
MESSAGE_STATUS,
|
|
8
|
+
SORT_ORDER,
|
|
9
|
+
type MessageGroupCategory,
|
|
10
|
+
type MessageGroupListSort,
|
|
11
|
+
type MessageGroupListVisibility,
|
|
12
|
+
type MessageGroupProduct,
|
|
13
|
+
type MessageStatus,
|
|
14
|
+
type RenderType,
|
|
15
|
+
type SortOrder,
|
|
16
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
17
|
+
import { usePagedMessageGroups } from '~/composables/usePagedMessageGroups'
|
|
18
|
+
import { useAppContextStore } from '~/stores/appContext'
|
|
19
|
+
|
|
20
|
+
/** Sentinel for "no filter" in the selects; never sent to the server. */
|
|
21
|
+
export const ALL_FILTER = 'all'
|
|
22
|
+
|
|
23
|
+
export interface MessageGroupListFilters {
|
|
24
|
+
search: string
|
|
25
|
+
category: MessageGroupCategory | typeof ALL_FILTER
|
|
26
|
+
status: MessageStatus | typeof ALL_FILTER
|
|
27
|
+
product: MessageGroupProduct | typeof ALL_FILTER
|
|
28
|
+
/** Only admins may ask for `internal` or `all`; non-admins always request `public`. */
|
|
29
|
+
visibility: MessageGroupListVisibility
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MessageGroupListSorting {
|
|
33
|
+
sort: MessageGroupListSort
|
|
34
|
+
order: SortOrder
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MessageGroupListState extends MessageGroupListFilters, MessageGroupListSorting {
|
|
38
|
+
page: number
|
|
39
|
+
perPage: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STORAGE_KEY = 'message_library_list_state'
|
|
43
|
+
const PER_PAGE = 25
|
|
44
|
+
|
|
45
|
+
function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
|
|
46
|
+
return typeof value === 'string' && (values as readonly string[]).includes(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type StoredState = Partial<Record<keyof MessageGroupListState | 'contextProduct', unknown>>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads the state saved by this composer session. State is scoped to the
|
|
53
|
+
* context Product it was saved under: after a host Product switch the library
|
|
54
|
+
* must open prefiltered on the new Product, not on a stale choice.
|
|
55
|
+
*/
|
|
56
|
+
function readStoredState(contextProduct: MessageGroupProduct | undefined): StoredState {
|
|
57
|
+
try {
|
|
58
|
+
const raw = sessionStorage.getItem(STORAGE_KEY)
|
|
59
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null
|
|
60
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
61
|
+
const stored = parsed as StoredState
|
|
62
|
+
return stored.contextProduct === contextProduct ? stored : {}
|
|
63
|
+
} catch {
|
|
64
|
+
return {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Restores the previous list state of this composer session, field by field:
|
|
70
|
+
* anything missing or no longer valid falls back to the default.
|
|
71
|
+
*/
|
|
72
|
+
function restoreState(
|
|
73
|
+
defaults: MessageGroupListState,
|
|
74
|
+
contextProduct: MessageGroupProduct | undefined,
|
|
75
|
+
): MessageGroupListState {
|
|
76
|
+
const stored = readStoredState(contextProduct)
|
|
77
|
+
const withAll = <T extends string>(values: readonly T[]) => [...values, ALL_FILTER] as const
|
|
78
|
+
return {
|
|
79
|
+
search: typeof stored.search === 'string' ? stored.search : defaults.search,
|
|
80
|
+
category: isOneOf(withAll(MESSAGE_GROUP_CATEGORIES), stored.category)
|
|
81
|
+
? stored.category
|
|
82
|
+
: defaults.category,
|
|
83
|
+
status: isOneOf(withAll(MESSAGE_STATUS), stored.status) ? stored.status : defaults.status,
|
|
84
|
+
product: isOneOf(withAll(MESSAGE_GROUP_PRODUCTS), stored.product)
|
|
85
|
+
? stored.product
|
|
86
|
+
: defaults.product,
|
|
87
|
+
visibility: isOneOf(MESSAGE_GROUP_LIST_VISIBILITY, stored.visibility)
|
|
88
|
+
? stored.visibility
|
|
89
|
+
: defaults.visibility,
|
|
90
|
+
sort: isOneOf(MESSAGE_GROUP_LIST_SORT, stored.sort) ? stored.sort : defaults.sort,
|
|
91
|
+
order: isOneOf(SORT_ORDER, stored.order) ? stored.order : defaults.order,
|
|
92
|
+
page:
|
|
93
|
+
Number.isInteger(stored.page) && (stored.page as number) >= 1 ? (stored.page as number) : 1,
|
|
94
|
+
perPage: defaults.perPage,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A select value of `all` means "no filter": the param is left out. */
|
|
99
|
+
function unlessAll<T extends string>(value: T | typeof ALL_FILTER): T | undefined {
|
|
100
|
+
return value === ALL_FILTER ? undefined : value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function persistState(
|
|
104
|
+
state: MessageGroupListState,
|
|
105
|
+
contextProduct: MessageGroupProduct | undefined,
|
|
106
|
+
) {
|
|
107
|
+
try {
|
|
108
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, contextProduct }))
|
|
109
|
+
} catch {
|
|
110
|
+
// Storage unavailable (private mode, quota): the list still works, it just won't be restored.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Owns the library table's state (filters, sorting, page) as component-scoped
|
|
116
|
+
* reactive state and exposes the server-side paginated query (ADR 0012).
|
|
117
|
+
*
|
|
118
|
+
* - The Product filter starts on the context Product and can be widened to all.
|
|
119
|
+
* - Any filter or sort change lands on page one; search is debounced.
|
|
120
|
+
* - The previous page stays visible while the next one loads.
|
|
121
|
+
* - State survives navigating into a group and back within the session, never the host URL.
|
|
122
|
+
*/
|
|
123
|
+
export function useMessageGroupList(renderType: MaybeRefOrGetter<RenderType>) {
|
|
124
|
+
const appContext = useAppContextStore()
|
|
125
|
+
|
|
126
|
+
const defaults: MessageGroupListState = {
|
|
127
|
+
search: '',
|
|
128
|
+
category: ALL_FILTER,
|
|
129
|
+
status: ALL_FILTER,
|
|
130
|
+
product: appContext.product ?? ALL_FILTER,
|
|
131
|
+
// Admins keep seeing internal and public groups by default, as before the
|
|
132
|
+
// server-side list; the select lets them narrow. Non-admins never send this.
|
|
133
|
+
visibility: appContext.isAdmin ? 'all' : 'public',
|
|
134
|
+
sort: 'updated_at',
|
|
135
|
+
order: 'desc',
|
|
136
|
+
page: 1,
|
|
137
|
+
perPage: PER_PAGE,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const state = reactive<MessageGroupListState>(restoreState(defaults, appContext.product))
|
|
141
|
+
|
|
142
|
+
const { rows, meta, loading, query } = usePagedMessageGroups({
|
|
143
|
+
search: toRef(state, 'search'),
|
|
144
|
+
page: toRef(state, 'page'),
|
|
145
|
+
perPage: PER_PAGE,
|
|
146
|
+
renderType,
|
|
147
|
+
filters: () => ({
|
|
148
|
+
category: unlessAll(state.category),
|
|
149
|
+
status: unlessAll(state.status),
|
|
150
|
+
product: unlessAll(state.product),
|
|
151
|
+
visibility: appContext.isAdmin ? state.visibility : 'public',
|
|
152
|
+
sort: state.sort,
|
|
153
|
+
order: state.order,
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
watch(state, (value) => persistState(value, appContext.product), { deep: true })
|
|
158
|
+
|
|
159
|
+
function setFilters(patch: Partial<MessageGroupListFilters>) {
|
|
160
|
+
Object.assign(state, patch)
|
|
161
|
+
// Search resets the page only once the debounced value reaches the query.
|
|
162
|
+
const { search: _search, ...immediate } = patch
|
|
163
|
+
if (Object.keys(immediate).length > 0) state.page = 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setSorting(sorting: MessageGroupListSorting) {
|
|
167
|
+
state.sort = sorting.sort
|
|
168
|
+
state.order = sorting.order
|
|
169
|
+
state.page = 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setPage(page: number) {
|
|
173
|
+
state.page = page
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
state: readonly(state),
|
|
178
|
+
setFilters,
|
|
179
|
+
setSorting,
|
|
180
|
+
setPage,
|
|
181
|
+
rows,
|
|
182
|
+
meta,
|
|
183
|
+
loading,
|
|
184
|
+
refetch: query.refetch,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useQuery } from '@pinia/colada'
|
|
2
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
3
|
+
import { computed, ref, toValue, watch } from 'vue'
|
|
4
|
+
import type {
|
|
5
|
+
EmailMessage,
|
|
6
|
+
MessageStatus,
|
|
7
|
+
PostEntity,
|
|
8
|
+
RenderType,
|
|
9
|
+
SendableMessage,
|
|
10
|
+
WhatsappMessage,
|
|
11
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
12
|
+
import { compilationApi } from '~/api'
|
|
13
|
+
import { useResolvedParametersQuery } from '~/queries/compilation'
|
|
14
|
+
import { useMessageGroupQuery } from '~/queries/messageGroups'
|
|
15
|
+
import { useTracking } from '~/composables/useTracking'
|
|
16
|
+
|
|
17
|
+
/** Codes a host receives on `error`; the backend ones pass through unchanged. */
|
|
18
|
+
export type MessageSendFlowErrorCode =
|
|
19
|
+
| 'NOT_SENDABLE'
|
|
20
|
+
| 'MISSING_PARAMETERS'
|
|
21
|
+
| 'TEMPLATE_NOT_SUBMITTED'
|
|
22
|
+
| 'TEMPLATE_NOT_APPROVED'
|
|
23
|
+
| 'COMPILE_FAILED'
|
|
24
|
+
|
|
25
|
+
const BACKEND_CODES = new Set<MessageSendFlowErrorCode>([
|
|
26
|
+
'TEMPLATE_NOT_SUBMITTED',
|
|
27
|
+
'TEMPLATE_NOT_APPROVED',
|
|
28
|
+
'MISSING_PARAMETERS',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export interface MessageSendFlowError {
|
|
32
|
+
code: MessageSendFlowErrorCode
|
|
33
|
+
/** The layer's translated text, the same string the inline alert shows. */
|
|
34
|
+
message: string
|
|
35
|
+
/** Missing keys (client-side) or the backend error body. */
|
|
36
|
+
details?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MessageSendFlowEmits {
|
|
40
|
+
compiled: [message: SendableMessage]
|
|
41
|
+
cancel: []
|
|
42
|
+
error: [error: MessageSendFlowError]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MessageSendFlowLanguage {
|
|
46
|
+
code: string
|
|
47
|
+
status: MessageStatus
|
|
48
|
+
approved: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What an operator must fill; `brand_*` keys are baked server-side. */
|
|
52
|
+
export function isOperatorParameter(key: string): boolean {
|
|
53
|
+
return !key.startsWith('brand_')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Data flow of the Message Send Flow (spec #67): Sendable group → approved
|
|
58
|
+
* language → parameters → one `/compile` on Confirm. The component owns the
|
|
59
|
+
* steps and the buttons; the host owns the container and the send.
|
|
60
|
+
*/
|
|
61
|
+
export function useMessageSendFlow(options: {
|
|
62
|
+
groupId: Ref<string | undefined>
|
|
63
|
+
renderType: MaybeRefOrGetter<RenderType>
|
|
64
|
+
preferredLanguage: MaybeRefOrGetter<string | undefined>
|
|
65
|
+
prefill: MaybeRefOrGetter<Record<string, string> | undefined>
|
|
66
|
+
brandId: MaybeRefOrGetter<string | undefined>
|
|
67
|
+
emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
|
|
68
|
+
}) {
|
|
69
|
+
const { groupId, emit } = options
|
|
70
|
+
const { t } = useI18n()
|
|
71
|
+
const { trackEvent } = useTracking()
|
|
72
|
+
|
|
73
|
+
// --- Group -------------------------------------------------------------
|
|
74
|
+
const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
|
|
75
|
+
enabled: () => !!groupId.value,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const messages = computed(() =>
|
|
79
|
+
(group.value?.messages ?? []).filter((m) => m.render_type === toValue(options.renderType)),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const languages = computed<MessageSendFlowLanguage[]>(() =>
|
|
83
|
+
messages.value.map((m) => ({
|
|
84
|
+
code: m.language_id,
|
|
85
|
+
status: m.status,
|
|
86
|
+
approved: m.status === 'approved',
|
|
87
|
+
})),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const sendable = computed(() => languages.value.some((l) => l.approved))
|
|
91
|
+
const notSendable = computed(
|
|
92
|
+
() => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// --- Language rule: preferred if approved, else first approved -----------
|
|
96
|
+
const selectedLanguage = ref<string>()
|
|
97
|
+
watch(
|
|
98
|
+
[messages, () => toValue(options.preferredLanguage)],
|
|
99
|
+
([msgs, preferred]) => {
|
|
100
|
+
const approved = msgs.filter((m) => m.status === 'approved')
|
|
101
|
+
const match = approved.find((m) => m.language_id === preferred) ?? approved[0]
|
|
102
|
+
selectedLanguage.value = match?.language_id
|
|
103
|
+
},
|
|
104
|
+
{ immediate: true },
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const selectedMessage = computed(
|
|
108
|
+
() =>
|
|
109
|
+
messages.value.find((m) => m.language_id === selectedLanguage.value) as
|
|
110
|
+
| PostEntity<WhatsappMessage>
|
|
111
|
+
| PostEntity<EmailMessage>
|
|
112
|
+
| undefined,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// --- Parameters ----------------------------------------------------------
|
|
116
|
+
const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
|
|
117
|
+
|
|
118
|
+
const { data: parametersData, isLoading: parametersLoading } = useQuery({
|
|
119
|
+
key: () => ['send-flow', 'parameters', groupId.value ?? '', selectedLanguage.value ?? ''],
|
|
120
|
+
query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
|
|
121
|
+
enabled: parametersEnabled,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Same query as WhatsappPreview, so the Preview step reuses the cached values.
|
|
125
|
+
const { data: resolvedData } = useResolvedParametersQuery(
|
|
126
|
+
() => groupId.value ?? '',
|
|
127
|
+
() => selectedLanguage.value ?? '',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const parameterKeys = computed(() =>
|
|
131
|
+
(parametersData.value?.parameters ?? []).filter(isOperatorParameter),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** Organization-level values, shown as input placeholders. */
|
|
135
|
+
const placeholders = computed<Record<string, string>>(() => resolvedData.value?.parameters ?? {})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One entry per visible key; prefill seeds new keys, unknown prefill keys
|
|
139
|
+
* are ignored. Rebuilt only once the keys are known, so a language switch
|
|
140
|
+
* keeps what the operator typed while the next list loads.
|
|
141
|
+
*/
|
|
142
|
+
const parameterValues = ref<Record<string, string>>({})
|
|
143
|
+
watch(
|
|
144
|
+
parameterKeys,
|
|
145
|
+
(keys) => {
|
|
146
|
+
if (!parametersData.value) return
|
|
147
|
+
const prefill = toValue(options.prefill) ?? {}
|
|
148
|
+
const next: Record<string, string> = {}
|
|
149
|
+
for (const key of keys) next[key] = parameterValues.value[key] ?? prefill[key] ?? ''
|
|
150
|
+
parameterValues.value = next
|
|
151
|
+
},
|
|
152
|
+
{ immediate: true },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const missing = computed(() =>
|
|
156
|
+
parameterKeys.value.filter((k) => !parameterValues.value[k]?.trim()),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// --- Errors ---------------------------------------------------------------
|
|
160
|
+
const flowError = ref<MessageSendFlowError>()
|
|
161
|
+
|
|
162
|
+
function fail(code: MessageSendFlowErrorCode, details?: unknown) {
|
|
163
|
+
const error: MessageSendFlowError = {
|
|
164
|
+
code,
|
|
165
|
+
message: t(`send_flow.errors.${code}`, { name: group.value?.name ?? '' }),
|
|
166
|
+
details,
|
|
167
|
+
}
|
|
168
|
+
flowError.value = error
|
|
169
|
+
trackEvent('send_flow.error', { code })
|
|
170
|
+
emit('error', error)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
watch(notSendable, (value) => {
|
|
174
|
+
if (value) {
|
|
175
|
+
fail('NOT_SENDABLE')
|
|
176
|
+
} else if (flowError.value?.code === 'NOT_SENDABLE') {
|
|
177
|
+
flowError.value = undefined
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// --- Compile (once, on Confirm) --------------------------------------------
|
|
182
|
+
const compiling = ref(false)
|
|
183
|
+
const canConfirm = computed(() => !!selectedMessage.value && !compiling.value)
|
|
184
|
+
|
|
185
|
+
async function confirm() {
|
|
186
|
+
flowError.value = undefined
|
|
187
|
+
if (!groupId.value || !selectedLanguage.value || !group.value) return
|
|
188
|
+
|
|
189
|
+
if (missing.value.length > 0) {
|
|
190
|
+
fail('MISSING_PARAMETERS', { missing: missing.value })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
trackEvent('send_flow.confirm', {
|
|
195
|
+
message_name: group.value.name,
|
|
196
|
+
message_category: group.value.category,
|
|
197
|
+
language: selectedLanguage.value,
|
|
198
|
+
parameters_count: parameterKeys.value.length,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
compiling.value = true
|
|
202
|
+
try {
|
|
203
|
+
const res = await compilationApi.compile(
|
|
204
|
+
groupId.value,
|
|
205
|
+
{
|
|
206
|
+
language: selectedLanguage.value,
|
|
207
|
+
parameters: { ...parameterValues.value },
|
|
208
|
+
strict: true,
|
|
209
|
+
strict_language: true,
|
|
210
|
+
brand_id: toValue(options.brandId),
|
|
211
|
+
},
|
|
212
|
+
{ skipErrorToast: true },
|
|
213
|
+
)
|
|
214
|
+
if (!('message' in res)) {
|
|
215
|
+
fail('COMPILE_FAILED', res)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
emit('compiled', res.message)
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const { status, data } = e as { status?: number; data?: { code?: string } }
|
|
221
|
+
// 401 reaches the host through `composer:auth-error`; nothing to add here.
|
|
222
|
+
if (status === 401) return
|
|
223
|
+
const code = data?.code as MessageSendFlowErrorCode | undefined
|
|
224
|
+
fail(code && BACKEND_CODES.has(code) ? code : 'COMPILE_FAILED', data ?? e)
|
|
225
|
+
} finally {
|
|
226
|
+
compiling.value = false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
group,
|
|
232
|
+
groupLoading,
|
|
233
|
+
languages,
|
|
234
|
+
sendable,
|
|
235
|
+
selectedLanguage,
|
|
236
|
+
selectedMessage,
|
|
237
|
+
parameterKeys,
|
|
238
|
+
parametersLoading,
|
|
239
|
+
placeholders,
|
|
240
|
+
parameterValues,
|
|
241
|
+
flowError,
|
|
242
|
+
compiling,
|
|
243
|
+
canConfirm,
|
|
244
|
+
confirm,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter, Ref, WatchSource } from 'vue'
|
|
2
|
+
import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import type { ListMessageGroupsOptions } from '~/api/messageGroups'
|
|
4
|
+
import { useMessageGroupListQuery } from '~/queries/messageGroups'
|
|
5
|
+
|
|
6
|
+
export const SEARCH_DEBOUNCE_MS = 300
|
|
7
|
+
|
|
8
|
+
type PagedListFilters = Omit<
|
|
9
|
+
ListMessageGroupsOptions,
|
|
10
|
+
'search' | 'page' | 'per_page' | 'render_type'
|
|
11
|
+
>
|
|
12
|
+
|
|
13
|
+
export interface PagedMessageGroupsOptions {
|
|
14
|
+
/** Raw search term as typed; it is debounced and trimmed before it reaches the request. */
|
|
15
|
+
search: Ref<string>
|
|
16
|
+
/** Current page. Lands on one whenever the search settles or a `resetOn` source changes. */
|
|
17
|
+
page: Ref<number>
|
|
18
|
+
perPage: number
|
|
19
|
+
renderType: MaybeRefOrGetter<RenderType | undefined>
|
|
20
|
+
/** Remaining filters and sorting, merged into the request as they are. */
|
|
21
|
+
filters?: MaybeRefOrGetter<PagedListFilters>
|
|
22
|
+
/** Further sources whose change lands on page one, besides the search and render type. */
|
|
23
|
+
resetOn?: WatchSource[]
|
|
24
|
+
/** When false no request is made (the query stays pending). */
|
|
25
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One server-side paginated Message Group list (ADR 0012) as a reactive source:
|
|
30
|
+
* debounced search, page reset, request options and the query itself. The
|
|
31
|
+
* library table and the in-composer picker both build on it and own only what
|
|
32
|
+
* differs (filters, page size, how rows are shown).
|
|
33
|
+
*/
|
|
34
|
+
export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
|
|
35
|
+
const debouncedSearch = refDebounced(options.search, SEARCH_DEBOUNCE_MS)
|
|
36
|
+
|
|
37
|
+
const listOptions = computed<ListMessageGroupsOptions>(() => ({
|
|
38
|
+
...toValue(options.filters),
|
|
39
|
+
render_type: toValue(options.renderType),
|
|
40
|
+
search: debouncedSearch.value.trim() || undefined,
|
|
41
|
+
page: options.page.value,
|
|
42
|
+
per_page: options.perPage,
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const query = useMessageGroupListQuery(listOptions, { enabled: options.enabled })
|
|
46
|
+
|
|
47
|
+
// The reset happens in the same tick as the change (sync flush) so the query
|
|
48
|
+
// sees one key change, not two, and the user never opens an empty page.
|
|
49
|
+
watch(
|
|
50
|
+
[debouncedSearch, () => toValue(options.renderType), ...(options.resetOn ?? [])],
|
|
51
|
+
() => {
|
|
52
|
+
options.page.value = 1
|
|
53
|
+
},
|
|
54
|
+
{ flush: 'sync' },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
debouncedSearch,
|
|
59
|
+
listOptions,
|
|
60
|
+
query,
|
|
61
|
+
rows: computed(() => query.data.value?.data ?? []),
|
|
62
|
+
meta: computed(() => query.data.value?.meta),
|
|
63
|
+
/** True on the first load and while a new page or filter set is fetching over previous data. */
|
|
64
|
+
loading: computed(() => query.isPending.value || query.isPlaceholderData.value),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
MessageStatus,
|
|
14
14
|
MessageGroup,
|
|
15
15
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
16
|
+
import { useAppContextStore } from '@/stores/appContext'
|
|
16
17
|
|
|
17
18
|
interface TemplateData<T = Message | PostEntity<Message>> {
|
|
18
19
|
messageGroup: { name: string; category: MessageGroupCategory; brand_id?: string | null }
|
|
@@ -27,6 +28,7 @@ interface UseTemplateOperationsOptions {
|
|
|
27
28
|
export function useTemplateOperations(options: UseTemplateOperationsOptions = {}) {
|
|
28
29
|
const saving = ref(false)
|
|
29
30
|
const saved = ref(false)
|
|
31
|
+
const appContext = useAppContextStore()
|
|
30
32
|
|
|
31
33
|
// Hoist composables to setup scope (they use inject() internally)
|
|
32
34
|
const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
|
|
@@ -39,9 +41,17 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
39
41
|
async function createTemplate(data: TemplateData, status: MessageStatus = 'draft') {
|
|
40
42
|
saving.value = true
|
|
41
43
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
44
|
+
// Every Message Group belongs to the context Product (ADR 0011).
|
|
45
|
+
const product = appContext.product
|
|
46
|
+
if (!product) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'[message-composer] Cannot create a template: no Product in the composer context',
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
const createdMessageGroup = await createMessageGroup({
|
|
52
|
+
...data.messageGroup,
|
|
53
|
+
product,
|
|
54
|
+
} as PostEntity<MessageGroup>)
|
|
45
55
|
|
|
46
56
|
if (createdMessageGroup?.id) {
|
|
47
57
|
for (const message of Object.values(data.messages)) {
|
package/app/layouts/default.vue
CHANGED
|
@@ -37,7 +37,7 @@ export function useResolvedParametersQuery(
|
|
|
37
37
|
export function useCompileEmailMutation() {
|
|
38
38
|
return useMutation({
|
|
39
39
|
mutation: ({ messageGroupId, data }: { messageGroupId: string; data: CompileMessageRequest }) =>
|
|
40
|
-
compilationApi.
|
|
40
|
+
compilationApi.compile(messageGroupId, data),
|
|
41
41
|
})
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useQuery } from '@pinia/colada'
|
|
2
|
+
import type { DynamicValuesResponse } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
3
|
import { dynamicValuesApi } from '~/api'
|
|
3
4
|
import { useAppContextStore } from '~/stores/appContext'
|
|
4
5
|
|
|
@@ -6,8 +7,13 @@ export function useDynamicValuesQuery() {
|
|
|
6
7
|
const { product, mainLanguage } = storeToRefs(useAppContextStore())
|
|
7
8
|
|
|
8
9
|
return useQuery({
|
|
9
|
-
key: () => ['dynamic-values', product.value, mainLanguage.value],
|
|
10
|
-
|
|
10
|
+
key: () => ['dynamic-values', product.value ?? '', mainLanguage.value],
|
|
11
|
+
// No Product, no request: the picker stays empty and the store lists the
|
|
12
|
+
// missing Product in `validationErrors`.
|
|
13
|
+
query: () => {
|
|
14
|
+
if (!product.value) return Promise.resolve<DynamicValuesResponse>({})
|
|
15
|
+
return dynamicValuesApi.get(product.value, mainLanguage.value || 'en')
|
|
16
|
+
},
|
|
11
17
|
staleTime: 30 * 60 * 1000,
|
|
12
18
|
})
|
|
13
19
|
}
|
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
3
|
import type {
|
|
3
4
|
DuplicateMessageGroupBody,
|
|
4
5
|
MessageGroup,
|
|
5
6
|
MessageGroupWithMessageSummary,
|
|
6
7
|
PostEntity,
|
|
7
|
-
RenderType,
|
|
8
8
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
9
9
|
import { messageGroupsApi } from '~/api'
|
|
10
|
+
import type { ListMessageGroupsOptions } from '~/api/messageGroups'
|
|
10
11
|
|
|
11
12
|
// QUERIES
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Colada merges `{ ...defaults, ...options }`, so forwarding `enabled: undefined`
|
|
16
|
+
* overrides its default `true` and silently disables the query: it then only
|
|
17
|
+
* runs on an explicit `refetch` (the library's 15 s poll, mutation invalidations).
|
|
18
|
+
* Forward `enabled` only when the caller set it.
|
|
19
|
+
*/
|
|
20
|
+
function enabledOption(enabled?: MaybeRefOrGetter<boolean>) {
|
|
21
|
+
return enabled === undefined ? {} : { enabled }
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One page of the server-side filtered list, in the Laravel envelope (ADR 0012).
|
|
25
|
+
* The previous page stays as placeholder data while the next one loads, so
|
|
26
|
+
* paging and filtering never flash an empty table.
|
|
27
|
+
*/
|
|
28
|
+
export function useMessageGroupListQuery(
|
|
29
|
+
options: MaybeRefOrGetter<ListMessageGroupsOptions>,
|
|
30
|
+
queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
|
|
19
31
|
) {
|
|
20
|
-
|
|
32
|
+
return useQuery({
|
|
21
33
|
key: () => ['message-groups', JSON.stringify(toValue(options))],
|
|
22
|
-
query: () => messageGroupsApi.
|
|
34
|
+
query: () => messageGroupsApi.list(toValue(options)),
|
|
23
35
|
staleTime: 0,
|
|
36
|
+
placeholderData: (previousData) => previousData,
|
|
37
|
+
...enabledOption(queryOptions.enabled),
|
|
24
38
|
})
|
|
25
|
-
|
|
26
|
-
// TODO: Implement polling when Pinia Colada supports it
|
|
27
|
-
// For now, polling can be done manually in the component using setInterval + refetch
|
|
28
|
-
|
|
29
|
-
return query
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
export function useMessageGroupQuery(
|
|
41
|
+
export function useMessageGroupQuery(
|
|
42
|
+
id: MaybeRefOrGetter<string>,
|
|
43
|
+
queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
|
|
44
|
+
) {
|
|
33
45
|
return useQuery({
|
|
34
46
|
key: () => ['message-groups', toValue(id)],
|
|
35
47
|
query: () => messageGroupsApi.getById(toValue(id)),
|
|
36
48
|
staleTime: 2 * 60 * 1000,
|
|
49
|
+
...enabledOption(queryOptions.enabled),
|
|
37
50
|
})
|
|
38
51
|
}
|
|
39
52
|
|
package/app/stores/appContext.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ref, computed, watch } from 'vue'
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
MESSAGE_GROUP_PRODUCTS,
|
|
4
|
+
type AppContextConfig,
|
|
5
|
+
type AppContextState,
|
|
5
6
|
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
7
|
|
|
7
8
|
export const useAppContextStore = defineStore('appContext', () => {
|
|
@@ -11,9 +12,10 @@ export const useAppContextStore = defineStore('appContext', () => {
|
|
|
11
12
|
|
|
12
13
|
// Proxy from injection with defaults (same interface as before)
|
|
13
14
|
const organization_id = computed(() => ctx.organization_id?.value ?? '')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// The Product is required by the context type (ADR 0011), but a host can
|
|
16
|
+
// still omit it at runtime: read defensively and list it in
|
|
17
|
+
// `validationErrors` rather than crashing or falling back to a fake Product.
|
|
18
|
+
const product = computed<AppContextConfig['product'] | undefined>(() => ctx.product?.value)
|
|
17
19
|
const strategy = computed<NonNullable<AppContextConfig['strategy']>>(
|
|
18
20
|
() => ctx.strategy?.value ?? 'native',
|
|
19
21
|
)
|
|
@@ -59,6 +61,10 @@ export const useAppContextStore = defineStore('appContext', () => {
|
|
|
59
61
|
const validationErrors = computed<Record<string, string>>(() => {
|
|
60
62
|
const errors: Record<string, string> = {}
|
|
61
63
|
|
|
64
|
+
if (!product.value) {
|
|
65
|
+
errors.product = `Product is required: pass \`product\` (${MESSAGE_GROUP_PRODUCTS.join(' | ')}) in the composer context`
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
if (channels.value?.includes('whatsapp') && !metaWabaId.value) {
|
|
63
69
|
errors.metaConfig = 'Meta WABA ID is required for WhatsApp'
|
|
64
70
|
}
|