@dev.smartpricing/message-composer-layer 4.2.5 → 4.2.6-brands.1

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/app/api/brands.ts CHANGED
@@ -21,7 +21,7 @@ export const brandsApi = {
21
21
  if (options?.sort_by) params.set('sort_by', options.sort_by)
22
22
  if (options?.direction) params.set('direction', options.direction)
23
23
  const query = params.size ? `?${params.toString()}` : ''
24
- return apiClient<BrandFromDb[]>(`/brands${query}`)
24
+ return apiClient<BrandWithProperties[]>(`/brands${query}`)
25
25
  },
26
26
 
27
27
  getById: (id: string) => apiClient<BrandWithProperties>(`/brands/${id}`),
@@ -6,13 +6,15 @@ import type {
6
6
  BrandDefaultButtonSettings,
7
7
  BrandCreateBody,
8
8
  BrandSocials,
9
+ BrandData,
9
10
  } from '@dev.smartpricing/message-composer-utils/types'
10
- import { isValidUrl } from '@dev.smartpricing/message-composer-utils/utils'
11
+ import { isValidUrl, isEmail } from '@dev.smartpricing/message-composer-utils/utils'
11
12
  import type { BrandCascadeData } from '@dev.smartpricing/message-composer-utils/utils'
12
13
  import type { BrandWithProperties } from '~/api/brands'
13
14
  import BrandPreview from '~/components/Brand/BrandPreview.vue'
14
15
  import BrandStepGeneral from '~/components/Brand/Steps/BrandStepGeneral.vue'
15
16
  import BrandStepVisual from '~/components/Brand/Steps/BrandStepVisual.vue'
17
+ import BrandStepContactInfo from '~/components/Brand/Steps/BrandStepContactInfo.vue'
16
18
  import BrandStepSocials from '~/components/Brand/Steps/BrandStepSocials.vue'
17
19
  import { useBrandQuery, useCreateBrandMutation, useUpdateBrandMutation } from '~/queries'
18
20
  import type { BrandCreateResponse } from '~/api/brands'
@@ -48,7 +50,7 @@ const isSaving = computed(() => isCreating.value || isUpdating.value)
48
50
  // ============================================================================
49
51
  // NAVIGATION
50
52
  // ============================================================================
51
- type Step = 'general' | 'visual' | 'socials'
53
+ type Step = 'general' | 'visual' | 'contact_info' | 'socials'
52
54
  const activeStep = ref<Step>('general')
53
55
 
54
56
  const navItems = computed<NavigationMenuItem[]>(() => [
@@ -68,6 +70,14 @@ const navItems = computed<NavigationMenuItem[]>(() => [
68
70
  activeStep.value = 'visual'
69
71
  },
70
72
  },
73
+ {
74
+ label: t('pages.brands.steps.contact_info'),
75
+ icon: 'ph:address-book',
76
+ active: activeStep.value === 'contact_info',
77
+ onSelect: () => {
78
+ activeStep.value = 'contact_info'
79
+ },
80
+ },
71
81
  {
72
82
  label: t('pages.brands.steps.socials'),
73
83
  icon: 'ph:share-network',
@@ -99,6 +109,7 @@ const defaultButtonSettings = ref<BrandDefaultButtonSettings>({
99
109
  backgroundColor: '#000000',
100
110
  })
101
111
 
112
+ const data = ref<BrandData>({})
102
113
  const socials = ref<BrandSocials>({})
103
114
 
104
115
  // ============================================================================
@@ -124,6 +135,7 @@ function populateForm(brand: BrandWithProperties) {
124
135
  colors.value = [...brand.colors]
125
136
  defaultEmailSettings.value = { ...brand.default_email_settings }
126
137
  defaultButtonSettings.value = { ...brand.default_button_settings }
138
+ data.value = brand.data ? { ...brand.data } : {}
127
139
  socials.value = brand.default_socials ? { ...brand.default_socials } : {}
128
140
  }
129
141
 
@@ -143,12 +155,25 @@ function resetForm() {
143
155
  textColor: '#FFFFFF',
144
156
  backgroundColor: '#000000',
145
157
  }
158
+ data.value = {}
146
159
  socials.value = {}
147
160
  activeStep.value = 'general'
148
161
  }
149
162
 
150
163
  watch(isOpen, (val) => {
151
- if (!val) resetForm()
164
+ if (!val) {
165
+ resetForm()
166
+ } else if (props.brandId && existingBrand.value) {
167
+ // Re-populate when reopening with the same brand (watcher won't fire since values didn't change)
168
+ populateForm(existingBrand.value)
169
+ }
170
+ })
171
+
172
+ // Pre-fill data.name with brand display name on create only
173
+ watch(activeStep, (step) => {
174
+ if (step === 'contact_info' && !isEditMode.value && !data.value.name && name.value.trim()) {
175
+ data.value = { ...data.value, name: name.value.trim() }
176
+ }
152
177
  })
153
178
 
154
179
  // ============================================================================
@@ -159,6 +184,7 @@ const brandCascadeData = computed<BrandCascadeData>(() => ({
159
184
  default_button_settings: defaultButtonSettings.value,
160
185
  default_socials: socials.value,
161
186
  logo_url: logoUrl.value || null,
187
+ data: data.value,
162
188
  }))
163
189
 
164
190
  // ============================================================================
@@ -168,6 +194,13 @@ function hasInvalidSocialUrls(): boolean {
168
194
  return Object.values(socials.value).some((url) => !!url && !isValidUrl(url))
169
195
  }
170
196
 
197
+ function hasInvalidContactInfo(): boolean {
198
+ const d = data.value
199
+ if (d.website && !isValidUrl(d.website)) return true
200
+ if (d.email && !isEmail(d.email)) return true
201
+ return false
202
+ }
203
+
171
204
  async function handleSave() {
172
205
  if (!name.value.trim()) {
173
206
  useToast().add({
@@ -178,6 +211,15 @@ async function handleSave() {
178
211
  return
179
212
  }
180
213
 
214
+ if (hasInvalidContactInfo()) {
215
+ useToast().add({
216
+ color: 'error',
217
+ title: t('pages.brands.validation.invalid_contact_info'),
218
+ })
219
+ activeStep.value = 'contact_info'
220
+ return
221
+ }
222
+
181
223
  if (hasInvalidSocialUrls()) {
182
224
  useToast().add({
183
225
  color: 'error',
@@ -194,6 +236,7 @@ async function handleSave() {
194
236
  default_email_settings: defaultEmailSettings.value,
195
237
  default_button_settings: defaultButtonSettings.value,
196
238
  default_socials: socials.value,
239
+ data: data.value,
197
240
  property_ids: selectedPropertyIds.value,
198
241
  }
199
242
 
@@ -221,6 +264,7 @@ async function handleSave() {
221
264
  changedFields.push('button_settings')
222
265
  if (JSON.stringify(payload.default_socials) !== JSON.stringify(brand.default_socials))
223
266
  changedFields.push('socials')
267
+ if (JSON.stringify(payload.data) !== JSON.stringify(brand.data)) changedFields.push('data')
224
268
  if (
225
269
  JSON.stringify(payload.property_ids) !==
226
270
  JSON.stringify(brand.properties?.map((bp) => bp.property_id))
@@ -291,6 +335,7 @@ async function handleSave() {
291
335
  v-if="activeStep === 'general'"
292
336
  v-model:name="name"
293
337
  v-model:selected-property-ids="selectedPropertyIds"
338
+ :brand-id="brandId"
294
339
  />
295
340
 
296
341
  <BrandStepVisual
@@ -302,6 +347,8 @@ async function handleSave() {
302
347
  v-model:default-button-settings="defaultButtonSettings"
303
348
  />
304
349
 
350
+ <BrandStepContactInfo v-if="activeStep === 'contact_info'" v-model="data" />
351
+
305
352
  <BrandStepSocials v-if="activeStep === 'socials'" v-model="socials" />
306
353
  </div>
307
354
 
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import type { BrandData } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { isValidUrl, isEmail } from '@dev.smartpricing/message-composer-utils/utils'
4
+
5
+ const data = defineModel<BrandData>({ required: true })
6
+
7
+ const { t, locale } = useI18n()
8
+
9
+ const touched = ref<Partial<Record<string, boolean>>>({})
10
+
11
+ function update(field: keyof BrandData, value: string) {
12
+ const trimmed = value.trim()
13
+ data.value = {
14
+ ...data.value,
15
+ [field]: trimmed || undefined,
16
+ }
17
+ }
18
+
19
+ function onBlur(field: string) {
20
+ const value = data.value[field as keyof BrandData]
21
+ if (value) {
22
+ touched.value = { ...touched.value, [field]: true }
23
+ }
24
+ }
25
+
26
+ function fieldError(field: string): string | undefined {
27
+ const value = data.value[field as keyof BrandData]
28
+ if (!value || !touched.value[field]) return undefined
29
+
30
+ if (field === 'website') {
31
+ return isValidUrl(value) ? undefined : t('pages.brands.validation.invalid_url_format')
32
+ }
33
+ if (field === 'email') {
34
+ return isEmail(value) ? undefined : t('pages.brands.validation.invalid_email_format')
35
+ }
36
+ return undefined
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div class="space-y-4">
42
+ <UFormField :label="t('pages.brands.fields.contact_name')">
43
+ <UInput
44
+ :model-value="data.name ?? ''"
45
+ :placeholder="t('pages.brands.fields.contact_name_placeholder')"
46
+ @update:model-value="update('name', $event as string)"
47
+ />
48
+ </UFormField>
49
+
50
+ <UFormField :label="t('pages.brands.fields.contact_address')">
51
+ <UInput
52
+ :model-value="data.address ?? ''"
53
+ :placeholder="t('pages.brands.fields.contact_address_placeholder')"
54
+ @update:model-value="update('address', $event as string)"
55
+ />
56
+ </UFormField>
57
+
58
+ <UFormField :label="t('pages.brands.fields.contact_telephone')">
59
+ <SPhoneInput
60
+ class="w-full"
61
+ :model-value="data.telephone ?? ''"
62
+ :locale="locale"
63
+ @update:model-value="update('telephone', $event as string)"
64
+ />
65
+ </UFormField>
66
+
67
+ <UFormField :label="t('pages.brands.fields.contact_email')" :error="fieldError('email')">
68
+ <UInput
69
+ :model-value="data.email ?? ''"
70
+ :placeholder="t('pages.brands.fields.contact_email_placeholder')"
71
+ :color="fieldError('email') ? 'error' : undefined"
72
+ @update:model-value="update('email', $event as string)"
73
+ @blur="onBlur('email')"
74
+ />
75
+ </UFormField>
76
+
77
+ <UFormField :label="t('pages.brands.fields.contact_website')" :error="fieldError('website')">
78
+ <UInput
79
+ :model-value="data.website ?? ''"
80
+ :placeholder="t('pages.brands.fields.contact_website_placeholder')"
81
+ :color="fieldError('website') ? 'error' : undefined"
82
+ @update:model-value="update('website', $event as string)"
83
+ @blur="onBlur('website')"
84
+ />
85
+ </UFormField>
86
+ </div>
87
+ </template>
@@ -1,16 +1,37 @@
1
1
  <script setup lang="ts">
2
2
  import type { Property } from '@dev.smartpricing/message-composer-utils/types'
3
- import { usePropertiesQuery } from '~/queries'
3
+ import { useBrandsQuery, usePropertiesQuery } from '~/queries'
4
+
5
+ const props = defineProps<{
6
+ brandId?: string
7
+ }>()
4
8
 
5
9
  const name = defineModel<string>('name', { required: true })
6
10
  const selectedPropertyIds = defineModel<string[]>('selectedPropertyIds', { required: true })
7
11
 
12
+ const { t } = useI18n()
13
+
8
14
  const { data: properties } = usePropertiesQuery()
15
+ const { data: brands } = useBrandsQuery()
16
+
17
+ /** Map of property_id → brand name, excluding the brand currently being edited */
18
+ const takenPropertyMap = computed(() => {
19
+ const map = new Map<string, string>()
20
+ for (const brand of brands.value ?? []) {
21
+ if (brand.id === props.brandId) continue
22
+ for (const bp of brand.properties ?? []) {
23
+ map.set(bp.property_id, brand.name)
24
+ }
25
+ }
26
+ return map
27
+ })
9
28
 
10
29
  const propertyItems = computed(() =>
11
30
  (properties.value ?? []).map((p: Property) => ({
12
31
  label: p.name,
13
32
  value: p.id,
33
+ disabled: takenPropertyMap.value.has(p.id),
34
+ takenByBrand: takenPropertyMap.value.get(p.id),
14
35
  })),
15
36
  )
16
37
  </script>
@@ -24,7 +45,7 @@ const propertyItems = computed(() =>
24
45
  />
25
46
  </UFormField>
26
47
 
27
- <!-- <UFormField :label="$t('pages.brands.fields.properties')">
48
+ <UFormField :label="$t('pages.brands.fields.properties')">
28
49
  <USelectMenu
29
50
  v-model="selectedPropertyIds"
30
51
  :items="propertyItems"
@@ -32,6 +53,18 @@ const propertyItems = computed(() =>
32
53
  value-key="value"
33
54
  :placeholder="$t('pages.brands.fields.properties_placeholder')"
34
55
  class="w-full"
35
- />
36
- </UFormField> -->
56
+ >
57
+ <template #item-label="{ item }">
58
+ <UTooltip
59
+ v-if="item.disabled"
60
+ :text="t('pages.brands.fields.property_taken_tooltip', { brand: item.takenByBrand })"
61
+ >
62
+ <span class="text-muted">
63
+ {{ item.label }}
64
+ </span>
65
+ </UTooltip>
66
+ <span v-else>{{ item.label }}</span>
67
+ </template>
68
+ </USelectMenu>
69
+ </UFormField>
37
70
  </template>
@@ -44,7 +44,7 @@ const buttonStyles = computed(() => {
44
44
  props.block.settings.shape === 'square'
45
45
  ? '0px'
46
46
  : props.block.settings.shape === 'rounded'
47
- ? '8px'
47
+ ? '4px'
48
48
  : '50px',
49
49
  padding: '12px 24px',
50
50
  border: 'none',
@@ -69,7 +69,7 @@ const containerStyles = computed(() => ({
69
69
  : props.block.settings.alignment === 'center'
70
70
  ? 'center'
71
71
  : 'flex-end',
72
- padding: '16px',
72
+ padding: '0',
73
73
  }))
74
74
 
75
75
  defineOptions({
@@ -35,15 +35,16 @@ const discountCodeStyles = computed(() => {
35
35
  props.block.settings.style === 'square'
36
36
  ? '0px'
37
37
  : props.block.settings.style === 'rounded'
38
- ? '8px'
38
+ ? '4px'
39
39
  : '50px',
40
- padding: '16px 24px',
40
+ padding: '12px 8px',
41
41
  textAlign: 'center',
42
- display: 'inline-block',
43
42
  }
44
43
 
45
44
  if (props.block.settings.width === 'full_width') {
46
45
  styles.width = '100%'
46
+ } else {
47
+ styles.display = 'inline-block'
47
48
  }
48
49
 
49
50
  return styles
@@ -52,7 +53,7 @@ const discountCodeStyles = computed(() => {
52
53
  const containerStyles = computed(() => {
53
54
  return {
54
55
  backgroundColor: props.block.settings.backgroundBlock,
55
- padding: '16px',
56
+ padding: '0',
56
57
  textAlign: 'center' as const,
57
58
  }
58
59
  })
@@ -12,7 +12,6 @@ defineOptions({
12
12
 
13
13
  <template>
14
14
  <div
15
- class="py-4"
16
15
  :style="{ backgroundColor: block.settings.backgroundColor }"
17
16
  :data-testid="dividerBlockTestIds.container"
18
17
  >
@@ -38,7 +38,7 @@ defineOptions({
38
38
 
39
39
  <template>
40
40
  <div
41
- class="rounded p-2 relative"
41
+ class="relative px-[25px]"
42
42
  :style="{ backgroundColor: block.settings.backgroundColor }"
43
43
  :data-testid="footerBlockTestIds.container"
44
44
  >
@@ -72,22 +72,18 @@ defineOptions({
72
72
 
73
73
  <template>
74
74
  <div
75
- class="rounded p-4"
76
75
  :style="{ backgroundColor: block.settings.backgroundColor }"
77
76
  :data-testid="gridBlockTestIds.container"
78
77
  >
79
- <div
80
- class="grid gap-4"
81
- :class="{
82
- 'grid-cols-1': block.settings.columns === 1,
83
- 'grid-cols-2': block.settings.columns === 2,
84
- 'grid-cols-3': block.settings.columns === 3,
85
- }"
86
- >
78
+ <div class="flex items-center">
87
79
  <div
88
80
  v-for="(column, index) in block.settings.columnData"
89
81
  :key="column.id"
90
- class="min-h-32 space-y-2 rounded border border-dashed border-neutral-300 p-2 overflow-hidden"
82
+ class="overflow-hidden [&_img]:w-full self-stretch flex flex-col justify-center"
83
+ :class="
84
+ column.blocks.length === 0 ? 'min-h-32 border border-dashed border-neutral-300/50' : ''
85
+ "
86
+ :style="{ width: `${100 / block.settings.columns}%` }"
91
87
  :data-testid="`${gridBlockTestIds.column}-${column.id}`"
92
88
  >
93
89
  <!-- Column blocks -->
@@ -38,7 +38,7 @@ defineOptions({
38
38
 
39
39
  <template>
40
40
  <div
41
- class="rounded p-2 relative"
41
+ class="relative px-[25px]"
42
42
  :style="{ backgroundColor: block.settings.backgroundColor }"
43
43
  :data-testid="headingBlockTestIds.container"
44
44
  >
@@ -96,10 +96,10 @@ defineOptions({
96
96
  :data-block-id="block.id"
97
97
  :data-block-type="block.type"
98
98
  :data-testid="`${indexBlockTestIds.container}-${block.id}`"
99
- class="group relative rounded border-2 transition-colors"
99
+ class="group relative transition-colors"
100
100
  :class="{
101
- 'border-primary-500': isSelected,
102
- 'border-transparent hover:border-neutral-300': !isSelected,
101
+ 'outline-2 outline-primary-500 outline': isSelected,
102
+ 'outline-transparent hover:outline-2 hover:outline-neutral-300 hover:outline': !isSelected,
103
103
  'cursor-move': showDragHandle,
104
104
  }"
105
105
  @click.stop="handleClick"
@@ -153,3 +153,14 @@ defineOptions({
153
153
  </div>
154
154
  </UPopover>
155
155
  </template>
156
+
157
+ <style scoped>
158
+ :deep(.ProseMirror ul) {
159
+ list-style-type: disc;
160
+ padding-left: 1.5em;
161
+ }
162
+ :deep(.ProseMirror ol) {
163
+ list-style-type: decimal;
164
+ padding-left: 1.5em;
165
+ }
166
+ </style>
@@ -38,7 +38,7 @@ defineOptions({
38
38
 
39
39
  <template>
40
40
  <div
41
- class="rounded p-2 relative"
41
+ class="relative px-[25px]"
42
42
  :style="{ backgroundColor: block.settings.backgroundColor }"
43
43
  :data-testid="paragraphBlockTestIds.container"
44
44
  >
@@ -36,7 +36,7 @@ defineOptions({
36
36
 
37
37
  <template>
38
38
  <div
39
- class="rounded p-2 relative"
39
+ class="relative px-[25px]"
40
40
  :style="{ backgroundColor: block.settings.backgroundColor }"
41
41
  :data-testid="unsubscribeLinkBlockTestIds.container"
42
42
  >
@@ -97,7 +97,7 @@ const accommodationName = computed(
97
97
 
98
98
  const state = ref<Schema>({
99
99
  to: '',
100
- sender_name: accommodationName.value,
100
+ sender_name: '',
101
101
  from: '',
102
102
  })
103
103
 
@@ -111,6 +111,16 @@ watch(
111
111
  { immediate: true },
112
112
  )
113
113
 
114
+ watch(
115
+ accommodationName,
116
+ (name) => {
117
+ if (!state.value.sender_name) {
118
+ state.value.sender_name = name
119
+ }
120
+ },
121
+ { immediate: true },
122
+ )
123
+
114
124
  const schema = z.object({
115
125
  to: z
116
126
  .string()