@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.5.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 +228 -46
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +15 -6
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +6 -2
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +29 -14
- package/app/api/meta.ts +26 -4
- package/app/api/organization.ts +10 -1
- package/app/api/tracking.ts +2 -2
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +164 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Common/OrganizationGate.vue +51 -0
- package/app/components/Common/OrganizationSelect.vue +27 -0
- package/app/components/Common/WhatsappNotConfigured.vue +20 -0
- package/app/components/Composer/EmailComposer.vue +4 -0
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/Composer/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -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 +9 -7
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +284 -0
- package/app/composables/usePagedMessageGroups.ts +78 -0
- package/app/composables/useTemplateOperations.ts +23 -4
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +5 -1
- package/app/pages/brands.vue +1 -0
- package/app/pages/images.vue +4 -0
- package/app/pages/index.vue +1 -0
- package/app/pages/preview/[id].vue +2 -0
- package/app/pages/template/whatsapp/[id].vue +1 -0
- package/app/queries/brands.ts +27 -7
- package/app/queries/compilation.ts +16 -3
- package/app/queries/dynamicValues.ts +19 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +56 -17
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +18 -48
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +84 -2
- package/i18n/locales/en.ts +80 -2
- package/i18n/locales/es.ts +81 -2
- package/i18n/locales/fr.ts +81 -2
- package/i18n/locales/it.ts +82 -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,73 @@
|
|
|
1
|
+
import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import WabaSelectModal from '~/components/Composer/WabaSelectModal.vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The WABA of the Message Group in the WhatsApp editor (ADR 0015): chosen at
|
|
6
|
+
* the first submission and fixed from then on.
|
|
7
|
+
*
|
|
8
|
+
* `storedWabaId` is the WABA the loaded Message Group is on, if any.
|
|
9
|
+
*/
|
|
10
|
+
export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null | undefined>) {
|
|
11
|
+
const { data: wabas, refresh } = useOrganizationWabasQuery()
|
|
12
|
+
const { metaWabaId } = storeToRefs(useAppContextStore())
|
|
13
|
+
const modal = useOverlay().create(WabaSelectModal)
|
|
14
|
+
|
|
15
|
+
// A submission of one language does not read the Message Group again: its
|
|
16
|
+
// WABA is remembered here until it does.
|
|
17
|
+
const submittedWabaId = ref<string>()
|
|
18
|
+
|
|
19
|
+
const wabaId = computed(() => toValue(storedWabaId) ?? submittedWabaId.value ?? null)
|
|
20
|
+
|
|
21
|
+
/** The WABA the Message Group is on, by id alone when it is not listed. */
|
|
22
|
+
const waba = computed<OrganizationWaba | null>(() => {
|
|
23
|
+
if (!wabaId.value) return null
|
|
24
|
+
return (
|
|
25
|
+
wabas.value?.find((candidate) => candidate.id === wabaId.value) ?? {
|
|
26
|
+
id: wabaId.value,
|
|
27
|
+
name: '',
|
|
28
|
+
phone_numbers: [],
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Message Group is on a WABA that is no longer one of its
|
|
35
|
+
* Organization's: it cannot be submitted any more. Never true while the
|
|
36
|
+
* lookup is running or failed.
|
|
37
|
+
*/
|
|
38
|
+
const isDetached = computed(
|
|
39
|
+
() =>
|
|
40
|
+
!!wabaId.value &&
|
|
41
|
+
!!wabas.value &&
|
|
42
|
+
!wabas.value.some((candidate) => candidate.id === wabaId.value),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The WABA to name in a submission. Asks the user only at the first
|
|
47
|
+
* submission of a Message Group whose Organization has several WABAs;
|
|
48
|
+
* otherwise names none and the backend resolves it. A lookup that has not
|
|
49
|
+
* answered yet is waited for; one that failed leaves it to the backend.
|
|
50
|
+
* Returns null when the user gives up.
|
|
51
|
+
*/
|
|
52
|
+
async function chooseWaba(): Promise<{ wabaId?: string } | null> {
|
|
53
|
+
if (wabaId.value) return {}
|
|
54
|
+
if (!wabas.value) await refresh()
|
|
55
|
+
const candidates = wabas.value
|
|
56
|
+
if (!candidates || candidates.length < 2) return {}
|
|
57
|
+
|
|
58
|
+
const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
|
|
59
|
+
? metaWabaId.value
|
|
60
|
+
: undefined
|
|
61
|
+
const chosen = await modal.open({ wabas: candidates, preselected }).result
|
|
62
|
+
|
|
63
|
+
return chosen ? { wabaId: chosen } : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** After a submission went through, with the WABA it named, if any. */
|
|
67
|
+
function markSubmitted(chosen?: string) {
|
|
68
|
+
if (wabaId.value) return
|
|
69
|
+
submittedWabaId.value = chosen ?? (wabas.value?.length === 1 ? wabas.value[0]!.id : undefined)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { waba, isDetached, chooseWaba, markSubmitted }
|
|
73
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
| 'NOT_OFFERED'
|
|
21
|
+
| 'MISSING_PARAMETERS'
|
|
22
|
+
| 'TEMPLATE_NOT_SUBMITTED'
|
|
23
|
+
| 'TEMPLATE_NOT_APPROVED'
|
|
24
|
+
| 'COMPILE_FAILED'
|
|
25
|
+
|
|
26
|
+
const BACKEND_CODES = new Set<MessageSendFlowErrorCode>([
|
|
27
|
+
'TEMPLATE_NOT_SUBMITTED',
|
|
28
|
+
'TEMPLATE_NOT_APPROVED',
|
|
29
|
+
'MISSING_PARAMETERS',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export interface MessageSendFlowError {
|
|
33
|
+
code: MessageSendFlowErrorCode
|
|
34
|
+
/** The layer's translated text, the same string the inline alert shows. */
|
|
35
|
+
message: string
|
|
36
|
+
/** Missing keys (client-side) or the backend error body. */
|
|
37
|
+
details?: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MessageSendFlowEmits {
|
|
41
|
+
compiled: [message: SendableMessage]
|
|
42
|
+
cancel: []
|
|
43
|
+
error: [error: MessageSendFlowError]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MessageSendFlowLanguage {
|
|
47
|
+
code: string
|
|
48
|
+
status: MessageStatus
|
|
49
|
+
approved: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What an operator must fill; `brand_*` keys are baked server-side. */
|
|
53
|
+
export function isOperatorParameter(key: string): boolean {
|
|
54
|
+
return !key.startsWith('brand_')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Data flow of the Message Send Flow (spec #67): Sendable group → approved
|
|
59
|
+
* language → parameters → one `/compile` on Confirm. The component owns the
|
|
60
|
+
* steps and the buttons; the host owns the container and the send.
|
|
61
|
+
*/
|
|
62
|
+
export function useMessageSendFlow(options: {
|
|
63
|
+
groupId: Ref<string | undefined>
|
|
64
|
+
renderType: MaybeRefOrGetter<RenderType>
|
|
65
|
+
preferredLanguage: MaybeRefOrGetter<string | undefined>
|
|
66
|
+
prefill: MaybeRefOrGetter<Record<string, string> | undefined>
|
|
67
|
+
brandId: MaybeRefOrGetter<string | undefined>
|
|
68
|
+
/** The Organization the flow works on; unset, the one the layer resolved. */
|
|
69
|
+
organizationId?: MaybeRefOrGetter<string | undefined>
|
|
70
|
+
/** The WABA the Host sends from: only WhatsApp groups on it are offered (ADR 0015). */
|
|
71
|
+
wabaId?: MaybeRefOrGetter<string | undefined>
|
|
72
|
+
emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
|
|
73
|
+
}) {
|
|
74
|
+
const { groupId, emit } = options
|
|
75
|
+
const { t } = useI18n()
|
|
76
|
+
const { trackEvent } = useTracking({ organizationId: options.organizationId })
|
|
77
|
+
|
|
78
|
+
// --- Group -------------------------------------------------------------
|
|
79
|
+
const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
|
|
80
|
+
enabled: () => !!groupId.value,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const messages = computed(() =>
|
|
84
|
+
(group.value?.messages ?? []).filter((m) => m.render_type === toValue(options.renderType)),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const languages = computed<MessageSendFlowLanguage[]>(() =>
|
|
88
|
+
messages.value.map((m) => ({
|
|
89
|
+
code: m.language_id,
|
|
90
|
+
status: m.status,
|
|
91
|
+
approved: m.status === 'approved',
|
|
92
|
+
})),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// A group reaches the flow by id too (the Host's preselection): one of
|
|
96
|
+
// another Organization, or on another WABA, is not the picker's to offer.
|
|
97
|
+
const offered = computed(() => {
|
|
98
|
+
if (!group.value) return true
|
|
99
|
+
const organizationId = toValue(options.organizationId)
|
|
100
|
+
if (
|
|
101
|
+
organizationId &&
|
|
102
|
+
group.value.organization_id?.toLowerCase() !== organizationId.toLowerCase()
|
|
103
|
+
)
|
|
104
|
+
return false
|
|
105
|
+
const wabaId = toValue(options.wabaId)
|
|
106
|
+
return (
|
|
107
|
+
!wabaId || toValue(options.renderType) !== 'whatsapp_v1' || group.value.waba_id === wabaId
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const sendable = computed(() => offered.value && languages.value.some((l) => l.approved))
|
|
112
|
+
const notSendable = computed(
|
|
113
|
+
() => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// --- Language rule: preferred if approved, else first approved -----------
|
|
117
|
+
const selectedLanguage = ref<string>()
|
|
118
|
+
watch(
|
|
119
|
+
[messages, () => toValue(options.preferredLanguage)],
|
|
120
|
+
([msgs, preferred]) => {
|
|
121
|
+
const approved = msgs.filter((m) => m.status === 'approved')
|
|
122
|
+
const match = approved.find((m) => m.language_id === preferred) ?? approved[0]
|
|
123
|
+
selectedLanguage.value = match?.language_id
|
|
124
|
+
},
|
|
125
|
+
{ immediate: true },
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const selectedMessage = computed(
|
|
129
|
+
() =>
|
|
130
|
+
messages.value.find((m) => m.language_id === selectedLanguage.value) as
|
|
131
|
+
| PostEntity<WhatsappMessage>
|
|
132
|
+
| PostEntity<EmailMessage>
|
|
133
|
+
| undefined,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// --- Parameters ----------------------------------------------------------
|
|
137
|
+
const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
|
|
138
|
+
const organization = useEffectiveOrganizationStore()
|
|
139
|
+
|
|
140
|
+
const { data: parametersData, isLoading: parametersLoading } = useQuery({
|
|
141
|
+
key: () => [
|
|
142
|
+
'send-flow',
|
|
143
|
+
'parameters',
|
|
144
|
+
groupId.value ?? '',
|
|
145
|
+
selectedLanguage.value ?? '',
|
|
146
|
+
organization.organizationId,
|
|
147
|
+
],
|
|
148
|
+
query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
|
|
149
|
+
enabled: parametersEnabled,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Same query as WhatsappPreview, so the Preview step reuses the cached values.
|
|
153
|
+
const { data: resolvedData } = useResolvedParametersQuery(
|
|
154
|
+
() => groupId.value ?? '',
|
|
155
|
+
() => selectedLanguage.value ?? '',
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const parameterKeys = computed(() =>
|
|
159
|
+
(parametersData.value?.parameters ?? []).filter(isOperatorParameter),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
/** Organization-level values, shown as input placeholders. */
|
|
163
|
+
const placeholders = computed<Record<string, string>>(() => resolvedData.value?.parameters ?? {})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One entry per visible key; prefill seeds new keys, unknown prefill keys
|
|
167
|
+
* are ignored. Rebuilt only once the keys are known, so a language switch
|
|
168
|
+
* keeps what the operator typed while the next list loads.
|
|
169
|
+
*/
|
|
170
|
+
const parameterValues = ref<Record<string, string>>({})
|
|
171
|
+
watch(
|
|
172
|
+
parameterKeys,
|
|
173
|
+
(keys) => {
|
|
174
|
+
if (!parametersData.value) return
|
|
175
|
+
const prefill = toValue(options.prefill) ?? {}
|
|
176
|
+
const next: Record<string, string> = {}
|
|
177
|
+
for (const key of keys) next[key] = parameterValues.value[key] ?? prefill[key] ?? ''
|
|
178
|
+
parameterValues.value = next
|
|
179
|
+
},
|
|
180
|
+
{ immediate: true },
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const missing = computed(() =>
|
|
184
|
+
parameterKeys.value.filter((k) => !parameterValues.value[k]?.trim()),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// --- Errors ---------------------------------------------------------------
|
|
188
|
+
const flowError = ref<MessageSendFlowError>()
|
|
189
|
+
|
|
190
|
+
function fail(code: MessageSendFlowErrorCode, details?: unknown) {
|
|
191
|
+
const error: MessageSendFlowError = {
|
|
192
|
+
code,
|
|
193
|
+
message: t(`send_flow.errors.${code}`, { name: group.value?.name ?? '' }),
|
|
194
|
+
details,
|
|
195
|
+
}
|
|
196
|
+
flowError.value = error
|
|
197
|
+
trackEvent('send_flow.error', { code })
|
|
198
|
+
emit('error', error)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The reason a picked group cannot enter the flow. Watched with the group,
|
|
202
|
+
// so another group, or another reason for the same one, is reported anew.
|
|
203
|
+
const groupError = computed<MessageSendFlowErrorCode | undefined>(() => {
|
|
204
|
+
if (!notSendable.value) return undefined
|
|
205
|
+
return offered.value ? 'NOT_SENDABLE' : 'NOT_OFFERED'
|
|
206
|
+
})
|
|
207
|
+
watch([groupError, () => group.value?.id], ([code]) => {
|
|
208
|
+
if (code) {
|
|
209
|
+
fail(code)
|
|
210
|
+
} else if (
|
|
211
|
+
flowError.value?.code === 'NOT_SENDABLE' ||
|
|
212
|
+
flowError.value?.code === 'NOT_OFFERED'
|
|
213
|
+
) {
|
|
214
|
+
flowError.value = undefined
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// --- Compile (once, on Confirm) --------------------------------------------
|
|
219
|
+
const compiling = ref(false)
|
|
220
|
+
const canConfirm = computed(() => sendable.value && !!selectedMessage.value && !compiling.value)
|
|
221
|
+
|
|
222
|
+
async function confirm() {
|
|
223
|
+
flowError.value = undefined
|
|
224
|
+
if (!groupId.value || !selectedLanguage.value || !group.value) return
|
|
225
|
+
|
|
226
|
+
if (missing.value.length > 0) {
|
|
227
|
+
fail('MISSING_PARAMETERS', { missing: missing.value })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
trackEvent('send_flow.confirm', {
|
|
232
|
+
message_name: group.value.name,
|
|
233
|
+
message_category: group.value.category,
|
|
234
|
+
language: selectedLanguage.value,
|
|
235
|
+
parameters_count: parameterKeys.value.length,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
compiling.value = true
|
|
239
|
+
try {
|
|
240
|
+
const res = await compilationApi.compile(
|
|
241
|
+
groupId.value,
|
|
242
|
+
{
|
|
243
|
+
language: selectedLanguage.value,
|
|
244
|
+
parameters: { ...parameterValues.value },
|
|
245
|
+
strict: true,
|
|
246
|
+
strict_language: true,
|
|
247
|
+
brand_id: toValue(options.brandId),
|
|
248
|
+
},
|
|
249
|
+
{ skipErrorToast: true },
|
|
250
|
+
)
|
|
251
|
+
if (!('message' in res)) {
|
|
252
|
+
fail('COMPILE_FAILED', res)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
emit('compiled', res.message)
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const { status, data } = e as { status?: number; data?: { code?: string } }
|
|
258
|
+
// 401 reaches the host through `composer:auth-error`; nothing to add here.
|
|
259
|
+
if (status === 401) return
|
|
260
|
+
const code = data?.code as MessageSendFlowErrorCode | undefined
|
|
261
|
+
fail(code && BACKEND_CODES.has(code) ? code : 'COMPILE_FAILED', data ?? e)
|
|
262
|
+
} finally {
|
|
263
|
+
compiling.value = false
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
group,
|
|
269
|
+
groupLoading,
|
|
270
|
+
languages,
|
|
271
|
+
sendable,
|
|
272
|
+
selectedLanguage,
|
|
273
|
+
selectedMessage,
|
|
274
|
+
parameterKeys,
|
|
275
|
+
parametersLoading,
|
|
276
|
+
placeholders,
|
|
277
|
+
parameterValues,
|
|
278
|
+
flowError,
|
|
279
|
+
compiling,
|
|
280
|
+
canConfirm,
|
|
281
|
+
confirm,
|
|
282
|
+
trackEvent,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/** Lists this Organization instead of the resolved one. */
|
|
27
|
+
organizationId?: MaybeRefOrGetter<string | undefined>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One server-side paginated Message Group list (ADR 0012) as a reactive source:
|
|
32
|
+
* debounced search, page reset, request options and the query itself. The
|
|
33
|
+
* library table and the in-composer picker both build on it and own only what
|
|
34
|
+
* differs (filters, page size, how rows are shown).
|
|
35
|
+
*/
|
|
36
|
+
export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
|
|
37
|
+
const debouncedSearch = refDebounced(options.search, SEARCH_DEBOUNCE_MS)
|
|
38
|
+
|
|
39
|
+
const listOptions = computed<ListMessageGroupsOptions>(() => ({
|
|
40
|
+
...toValue(options.filters),
|
|
41
|
+
render_type: toValue(options.renderType),
|
|
42
|
+
search: debouncedSearch.value.trim() || undefined,
|
|
43
|
+
page: options.page.value,
|
|
44
|
+
per_page: options.perPage,
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
const query = useMessageGroupListQuery(listOptions, {
|
|
48
|
+
enabled: options.enabled,
|
|
49
|
+
organizationId: options.organizationId,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// The reset happens in the same tick as the change (sync flush) so the query
|
|
53
|
+
// sees one key change, not two, and the user never opens an empty page.
|
|
54
|
+
const organization = useEffectiveOrganizationStore()
|
|
55
|
+
watch(
|
|
56
|
+
[
|
|
57
|
+
debouncedSearch,
|
|
58
|
+
() => toValue(options.renderType),
|
|
59
|
+
// Another Organization is another list: its page N may not exist.
|
|
60
|
+
() => toValue(options.organizationId) || organization.resolvedOrganizationId,
|
|
61
|
+
...(options.resetOn ?? []),
|
|
62
|
+
],
|
|
63
|
+
() => {
|
|
64
|
+
options.page.value = 1
|
|
65
|
+
},
|
|
66
|
+
{ flush: 'sync' },
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
debouncedSearch,
|
|
71
|
+
listOptions,
|
|
72
|
+
query,
|
|
73
|
+
rows: computed(() => query.data.value?.data ?? []),
|
|
74
|
+
meta: computed(() => query.data.value?.meta),
|
|
75
|
+
/** True on the first load and while a new page or filter set is fetching over previous data. */
|
|
76
|
+
loading: computed(() => query.isPending.value || query.isPlaceholderData.value),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -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,8 @@ 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()
|
|
32
|
+
const organization = useEffectiveOrganizationStore()
|
|
30
33
|
|
|
31
34
|
// Hoist composables to setup scope (they use inject() internally)
|
|
32
35
|
const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
|
|
@@ -39,9 +42,20 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
39
42
|
async function createTemplate(data: TemplateData, status: MessageStatus = 'draft') {
|
|
40
43
|
saving.value = true
|
|
41
44
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
+
// Every Message Group belongs to the context Product (ADR 0011).
|
|
46
|
+
const product = appContext.product
|
|
47
|
+
if (!product) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'[message-composer] Cannot create a template: no Product in the composer context',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const createdMessageGroup = await createMessageGroup({
|
|
53
|
+
...data.messageGroup,
|
|
54
|
+
product,
|
|
55
|
+
// The Effective Organization (ADR 0014): the selected one, or the
|
|
56
|
+
// edited group's on "save as new".
|
|
57
|
+
organization_id: organization.requireOrganizationId(),
|
|
58
|
+
} as PostEntity<MessageGroup>)
|
|
45
59
|
|
|
46
60
|
if (createdMessageGroup?.id) {
|
|
47
61
|
for (const message of Object.values(data.messages)) {
|
|
@@ -102,7 +116,12 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
|
|
|
102
116
|
try {
|
|
103
117
|
const result = await duplicateMessageGroup({
|
|
104
118
|
id: sourceId,
|
|
105
|
-
data:
|
|
119
|
+
data: {
|
|
120
|
+
...(newName && { name: newName }),
|
|
121
|
+
// The Effective Organization (ADR 0014): the selected one, or the
|
|
122
|
+
// edited group's on "save as new".
|
|
123
|
+
organization_id: organization.requireOrganizationId(),
|
|
124
|
+
},
|
|
106
125
|
})
|
|
107
126
|
|
|
108
127
|
saved.value = true
|