@designcrowd/library.brand-page 6.0.12 → 6.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-BfQcnhof.js → CreatedOnBrandCrowd-de-de-CBjFFeBP.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-B86JfNzV.js → CreatedOnBrandCrowd-en-us-NuNXF81K.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-Dy70BV7X.js → CreatedOnBrandCrowd-es-es-BJG8nfug.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-CQ8UhGRG.js → CreatedOnBrandCrowd-fr-ca-uX-nL53e.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-CQ8UhGRG.js → CreatedOnBrandCrowd-fr-fr-uX-nL53e.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-c257u4q-.js → CreatedOnBrandCrowd-pt-br-DTrgFGQV.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-c257u4q-.js → CreatedOnBrandCrowd-pt-pt-DTrgFGQV.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-cRZ_Lx3_.js → CreatedOnDcom-de-de-DSfpxioT.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CrfHKR-1.js → CreatedOnDcom-en-us-C7G4gKem.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-BFjhMPTR.js → CreatedOnDcom-es-es-DVckCaji.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CZimjPcn.js → CreatedOnDcom-fr-ca-C5GDrm_C.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-V1z3-WzE.js → CreatedOnDcom-fr-fr-v5eMm-j2.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-NrmWKrvi.js → CreatedOnDcom-pt-br-Dq3-fPUc.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-CKLASXM9.js → CreatedOnDcom-pt-pt-DFsncYax.js} +1 -1
  15. package/lib/src/brand-page/components/image-gallery/ImageGallery.stories.js +3 -0
  16. package/lib/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.d.ts +1 -1
  17. package/lib/src/brand-page/components/images-section/ImagesSection.stories.js +2 -1
  18. package/lib/src/brand-page/components/social-links/SocialLinks.stories.js +3 -0
  19. package/lib/src/brand-page/components/testimonial-section/__fixtures__/data.js +3 -0
  20. package/lib/src/brand-page/components/text/Text.stories.js +2 -0
  21. package/lib/src/brand-page/models/index.d.ts +13 -0
  22. package/lib/src/brand-page/models/index.js +4 -0
  23. package/lib/src/brand-page/utils/migration/migration-01.js +2 -0
  24. package/lib/src/brand-page/utils/migration/migration-18.d.ts +14 -0
  25. package/lib/src/brand-page/utils/migration/migration-18.js +17 -0
  26. package/lib/src/brand-page/utils/migration.util.js +6 -0
  27. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.js +1 -0
  28. package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +1 -0
  29. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +5 -0
  30. package/lib/src/brand-page-maker/constants/config-settings-constant.js +4 -0
  31. package/lib/src/brand-page-maker/models/content-settings.js +4 -0
  32. package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +18 -0
  33. package/lib/src/index.mjs +1 -1
  34. package/lib/src/{lib-CHfUKgt-.js → lib-BG-pyXi9.js} +19091 -18974
  35. package/lib/src/metadata-builder/ai-metadata-executor.js +24 -1
  36. package/lib/src/metadata-builder/builders/footer-builder.js +32 -41
  37. package/lib/src/metadata-builder/builders/item-id-utils.d.ts +54 -0
  38. package/lib/src/metadata-builder/builders/item-id-utils.js +96 -0
  39. package/lib/src/metadata-builder/builders/move-page-builder.d.ts +32 -0
  40. package/lib/src/metadata-builder/builders/move-page-builder.js +137 -0
  41. package/lib/src/metadata-builder/builders/nav-utils.d.ts +26 -0
  42. package/lib/src/metadata-builder/builders/nav-utils.js +95 -0
  43. package/lib/src/metadata-builder/builders/section-builders/image-gallery-builder.js +57 -68
  44. package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +58 -93
  45. package/lib/src/metadata-builder/builders/section-builders/move-section-builder.d.ts +13 -1
  46. package/lib/src/metadata-builder/builders/section-builders/move-section-builder.js +40 -30
  47. package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +66 -99
  48. package/lib/src/metadata-builder/builders/section-builders/social-links-builder.js +57 -17
  49. package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +51 -85
  50. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +50 -74
  51. package/lib/src/metadata-builder/builders/section-field-maps.js +17 -0
  52. package/lib/src/metadata-builder/builders/set-page-visibility-builder.d.ts +23 -0
  53. package/lib/src/metadata-builder/builders/set-page-visibility-builder.js +83 -0
  54. package/lib/src/metadata-builder/metadata-executor.js +4 -0
  55. package/lib/src/metadata-builder/models.d.ts +2 -0
  56. package/lib/src/metadata-builder/models.js +2 -0
  57. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +31 -7
  58. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +2 -0
  59. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +84 -48
  60. package/lib/src/shared/content-settings/components/links-field/LinksField.mixin.js +2 -0
  61. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -0
  62. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -0
  63. package/lib/src/utils/minify-brand-page-metadata.d.ts +2 -0
  64. package/lib/src/utils/minify-brand-page-metadata.js +8 -1
  65. package/package.json +1 -1
  66. package/src/brand-page/components/image-gallery/ImageGallery.stories.ts +3 -0
  67. package/src/brand-page/components/image-gallery/ImageGallery.test.ts +1 -0
  68. package/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.ts +2 -1
  69. package/src/brand-page/components/images-section/ImagesSection.stories.ts +2 -1
  70. package/src/brand-page/components/social-links/SocialLinks.stories.ts +3 -0
  71. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +5 -0
  72. package/src/brand-page/components/testimonial-section/__fixtures__/data.ts +3 -0
  73. package/src/brand-page/components/text/Text.stories.ts +2 -0
  74. package/src/brand-page/models/index.ts +17 -0
  75. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +23 -3
  76. package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +6 -0
  77. package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +12 -0
  78. package/src/brand-page/utils/migration/migration-01.ts +2 -0
  79. package/src/brand-page/utils/migration/migration-18.test.ts +131 -0
  80. package/src/brand-page/utils/migration/migration-18.ts +22 -0
  81. package/src/brand-page/utils/migration.util.ts +7 -0
  82. package/src/brand-page/utils/personalisation.util.test.ts +4 -0
  83. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.ts +1 -0
  84. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +1 -0
  85. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +6 -0
  86. package/src/brand-page-maker/constants/config-settings-constant.ts +4 -0
  87. package/src/brand-page-maker/models/content-settings.ts +4 -0
  88. package/src/metadata-builder/ai-metadata-executor.ts +31 -1
  89. package/src/metadata-builder/builders/footer-builder.test.ts +335 -28
  90. package/src/metadata-builder/builders/footer-builder.ts +40 -57
  91. package/src/metadata-builder/builders/item-id-utils.test.ts +184 -0
  92. package/src/metadata-builder/builders/item-id-utils.ts +117 -0
  93. package/src/metadata-builder/builders/move-page-builder.ts +227 -0
  94. package/src/metadata-builder/builders/nav-utils.ts +141 -0
  95. package/src/metadata-builder/builders/section-builders/image-gallery-builder.ts +62 -88
  96. package/src/metadata-builder/builders/section-builders/images-section-builder.ts +66 -129
  97. package/src/metadata-builder/builders/section-builders/move-section-builder.ts +58 -35
  98. package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +105 -160
  99. package/src/metadata-builder/builders/section-builders/social-links-builder.ts +65 -20
  100. package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +83 -146
  101. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +71 -122
  102. package/src/metadata-builder/builders/section-field-maps.ts +18 -0
  103. package/src/metadata-builder/builders/set-page-visibility-builder.ts +122 -0
  104. package/src/metadata-builder/metadata-executor.ts +4 -0
  105. package/src/metadata-builder/models.ts +2 -0
  106. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +32 -7
  107. package/src/metadata-builder/parsers/section-tool-arguments.ts +84 -48
  108. package/src/shared/content-settings/components/links-field/LinksField.mixin.ts +2 -0
  109. package/src/utils/minify-brand-page-metadata.ts +14 -2
@@ -25,6 +25,12 @@ import {
25
25
  validateTestimonialSection,
26
26
  TESTIMONIAL_ITEM_CAP,
27
27
  } from '../section-rules/testimonial-section.rules';
28
+ import {
29
+ ensureItemIds,
30
+ generateItemId,
31
+ removeItemsByIds,
32
+ repositionItemByAnchor,
33
+ } from '../item-id-utils';
28
34
 
29
35
  const resolveItemColorsAsync = async (
30
36
  item: TestimonialItem,
@@ -50,6 +56,7 @@ const applyTestimonialItemsAsync = async (
50
56
  ): Promise<void> => {
51
57
  content.items = await Promise.all(
52
58
  items.map(async (item) => ({
59
+ id: item.item_id || generateItemId(),
53
60
  imageUrl: item.avatar_shape_url || item.avatar_image_url || '',
54
61
  name: item.name || '',
55
62
  position: item.position || '',
@@ -60,101 +67,77 @@ const applyTestimonialItemsAsync = async (
60
67
  );
61
68
  };
62
69
 
70
+ /**
71
+ * Apply a batch of item operations by id (BP-5603), mirroring the section-level
72
+ * id-based contract one level deeper:
73
+ * - item with `item_id` matching an existing item → update in place (and
74
+ * reposition if `move_before_item_id` is set);
75
+ * - item without `item_id` → add a new item,
76
+ * positioned by `move_before_item_id`;
77
+ * - `_remove_item_ids` → delete by id.
78
+ *
79
+ * Existing items are id-backfilled first so legacy sections become referenceable
80
+ * the moment the metadata builder touches them. Order-independent: there are no
81
+ * positional indices to shift between operations.
82
+ */
63
83
  const mergeTestimonialItemsAsync = async (
64
84
  content: TestimonialSectionContent,
65
85
  items: TestimonialItem[],
66
- replaceAll: boolean,
86
+ removeIds: string[] | undefined,
67
87
  ): Promise<void> => {
68
- const hasIndexedItems = items.some((item) => item.item_index !== undefined);
69
-
70
- if (hasIndexedItems) {
71
- await Promise.all(
72
- items.map(async (item) => {
73
- const targetIndex = item.item_index;
74
-
75
- if (
76
- targetIndex === undefined ||
77
- targetIndex < 0 ||
78
- !content.items?.[targetIndex]
79
- )
80
- return;
81
-
82
- const existing = content.items[targetIndex];
83
- const prevImageUrl = existing.imageUrl;
84
-
85
- if (item.avatar_shape_url !== undefined) {
86
- existing.imageUrl = item.avatar_shape_url;
87
- existing.mediaType = 'shape';
88
- } else if (item.avatar_image_url !== undefined) {
89
- existing.imageUrl = item.avatar_image_url;
90
- existing.mediaType = 'image';
91
- }
92
-
93
- if (item.name !== undefined) existing.name = item.name;
94
-
95
- if (item.position !== undefined) existing.position = item.position;
96
-
97
- if (item.testimonial !== undefined)
98
- existing.testimonials = item.testimonial;
99
-
100
- if (
101
- item.avatar_shape_url !== undefined ||
102
- item.color_theme !== undefined
103
- ) {
104
- existing.colorReplacements = await resolveItemColorsAsync(
105
- item,
106
- existing.colorReplacements,
107
- prevImageUrl,
108
- );
109
- }
110
- }),
111
- );
88
+ ensureItemIds(content.items);
89
+ content.items = removeItemsByIds(content.items, removeIds);
112
90
 
113
- return;
114
- }
91
+ for (const item of items) {
92
+ if (item.item_id) {
93
+ const existing = content.items.find((i) => i.id === item.item_id);
94
+
95
+ // Unknown id — skip rather than guess at a position.
96
+ if (!existing) continue;
115
97
 
116
- const itemsToMerge = items.filter((item) => item.insert_at === undefined);
98
+ const prevImageUrl = existing.imageUrl;
117
99
 
118
- const updatedItems = await Promise.all(
119
- itemsToMerge.map(async (item, index) => {
120
- const existing = content.items?.[index];
100
+ if (item.avatar_shape_url !== undefined) {
101
+ existing.imageUrl = item.avatar_shape_url;
102
+ existing.mediaType = 'shape';
103
+ } else if (item.avatar_image_url !== undefined) {
104
+ existing.imageUrl = item.avatar_image_url;
105
+ existing.mediaType = 'image';
106
+ }
121
107
 
122
- const imageUrl =
123
- item.avatar_shape_url ??
124
- item.avatar_image_url ??
125
- existing?.imageUrl ??
126
- '';
127
- const mediaType = item.avatar_shape_url
128
- ? 'shape'
129
- : item.avatar_image_url
130
- ? 'image'
131
- : (existing?.mediaType ?? 'image');
108
+ if (item.name !== undefined) existing.name = item.name;
132
109
 
133
- return {
134
- ...(existing || {}),
135
- imageUrl,
136
- name: item.name ?? existing?.name ?? '',
137
- position: item.position ?? existing?.position ?? '',
138
- testimonials: item.testimonial ?? existing?.testimonials ?? '',
139
- colorReplacements: await resolveItemColorsAsync(
110
+ if (item.position !== undefined) existing.position = item.position;
111
+
112
+ if (item.testimonial !== undefined)
113
+ existing.testimonials = item.testimonial;
114
+
115
+ if (
116
+ item.avatar_shape_url !== undefined ||
117
+ item.color_theme !== undefined
118
+ ) {
119
+ existing.colorReplacements = await resolveItemColorsAsync(
140
120
  item,
141
- existing?.colorReplacements,
142
- existing?.imageUrl,
143
- ),
144
- mediaType,
145
- };
146
- }),
147
- );
121
+ existing.colorReplacements,
122
+ prevImageUrl,
123
+ );
124
+ }
148
125
 
149
- if (
150
- !replaceAll &&
151
- content.items &&
152
- content.items.length > itemsToMerge.length
153
- ) {
154
- updatedItems.push(...content.items.slice(itemsToMerge.length));
126
+ repositionItemByAnchor(content.items, existing, item.move_before_item_id);
127
+ } else {
128
+ const newItem = {
129
+ id: generateItemId(),
130
+ imageUrl: item.avatar_shape_url ?? item.avatar_image_url ?? '',
131
+ name: item.name ?? '',
132
+ position: item.position ?? '',
133
+ testimonials: item.testimonial ?? '',
134
+ colorReplacements: await resolveItemColorsAsync(item, [], undefined),
135
+ mediaType: item.avatar_shape_url ? 'shape' : 'image',
136
+ } as TestimonialSectionContent['items'][number];
137
+
138
+ repositionItemByAnchor(content.items, newItem, item.move_before_item_id);
139
+ }
155
140
  }
156
-
157
- content.items = updatedItems;
158
141
  };
159
142
 
160
143
  export class TestimonialSectionBuilder extends BaseSectionBuilder<TestimonialSectionContent> {
@@ -228,75 +211,29 @@ export class TestimonialSectionBuilder extends BaseSectionBuilder<TestimonialSec
228
211
  };
229
212
  }
230
213
 
231
- if (args.items && Array.isArray(args.items)) {
232
- if (this.mode === 'create') {
214
+ if (args._replace_items && args.items && Array.isArray(args.items)) {
215
+ // Full replacement: discard existing items and rebuild from the payload.
216
+ await applyTestimonialItemsAsync(content, args.items);
217
+ } else if (this.mode === 'create') {
218
+ if (args.items && Array.isArray(args.items)) {
233
219
  await applyTestimonialItemsAsync(content, args.items);
234
- } else {
235
- await mergeTestimonialItemsAsync(
236
- content,
237
- args.items,
238
- !!args._replace_items,
239
- );
240
- }
241
- }
242
-
243
- // Phase 2: Item operations (remove, insert, reorder)
244
- if (args._remove_item_indices?.length && content.items) {
245
- const sorted = [...args._remove_item_indices].sort((a, b) => b - a);
246
-
247
- for (const idx of sorted) {
248
- if (idx >= 0 && idx < content.items.length) {
249
- content.items.splice(idx, 1);
250
- }
251
220
  }
252
- }
253
-
254
- if (args.items && Array.isArray(args.items)) {
255
- const insertItems = args.items.filter(
256
- (item) => item.insert_at !== undefined && item.item_index === undefined,
257
- );
258
- const sortedInserts = [...insertItems].sort(
259
- (a, b) => (b.insert_at ?? 0) - (a.insert_at ?? 0),
221
+ } else if (
222
+ (args.items && Array.isArray(args.items)) ||
223
+ args._remove_item_ids?.length
224
+ ) {
225
+ // Update: id-based add / edit / move / remove in a single pass.
226
+ await mergeTestimonialItemsAsync(
227
+ content,
228
+ args.items ?? [],
229
+ args._remove_item_ids,
260
230
  );
261
-
262
- for (const item of sortedInserts) {
263
- const pos = Math.max(
264
- 0,
265
- Math.min(item.insert_at ?? 0, content.items?.length ?? 0),
266
- );
267
- const newItem = {
268
- imageUrl: item.avatar_shape_url ?? item.avatar_image_url ?? '',
269
- name: item.name ?? '',
270
- position: item.position ?? '',
271
- testimonials: item.testimonial ?? '',
272
- colorReplacements: await resolveItemColorsAsync(item, [], undefined),
273
- mediaType: item.avatar_shape_url ? 'shape' : 'image',
274
- };
275
-
276
- content.items = content.items ?? [];
277
- content.items.splice(pos, 0, newItem as any);
278
- }
279
- }
280
-
281
- if (args._reorder_items?.length && content.items) {
282
- const newOrder = args._reorder_items;
283
- const unique = new Set(newOrder);
284
-
285
- if (unique.size === newOrder.length) {
286
- const reordered = newOrder
287
- .filter((idx) => idx >= 0 && idx < content.items!.length)
288
- .map((idx) => content.items![idx]);
289
-
290
- if (reordered.length === content.items.length) {
291
- content.items = reordered;
292
- }
293
- }
294
231
  }
295
232
 
296
233
  // Post-merge cap check. itemCapCheck only fires for create / update with
297
- // _replace_items, so insert_at on an already-full section silently bloats
298
- // the array. Surface as an error so the AI retry loop gets a signal and
299
- // can reissue with _remove_item_indices or fewer inserts.
234
+ // _replace_items, so adding new items to an already-full section silently
235
+ // bloats the array. Surface as an error so the AI retry loop gets a signal
236
+ // and can reissue with _remove_item_ids or fewer additions.
300
237
  // brandSite key mirrors base-section-builder's hardcoded brandPageType.
301
238
  const cap = TESTIMONIAL_ITEM_CAP.brandSite;
302
239
 
@@ -19,6 +19,12 @@ import {
19
19
  } from '../section-field-utils';
20
20
  import { BaseSectionBuilder } from './base-section-builder';
21
21
  import { validateTextSection } from '../section-rules/text-section.rules';
22
+ import {
23
+ ensureItemIds,
24
+ generateItemId,
25
+ removeItemsByIds,
26
+ repositionItemByAnchor,
27
+ } from '../item-id-utils';
22
28
 
23
29
  /** Resolve a RichTextField (string or TiptapDocument) to a plain HTML string. */
24
30
  const resolveText = (value: unknown): { html: string; wasTiptap: boolean } => {
@@ -52,6 +58,7 @@ const createTextItem = (
52
58
  const { html, wasTiptap } = resolveText(item.text);
53
59
 
54
60
  return {
61
+ id: item.item_id || generateItemId(),
55
62
  text: html,
56
63
  textStyle: clearTextStyleTypeIfTiptap(
57
64
  textStyle,
@@ -61,6 +68,10 @@ const createTextItem = (
61
68
  };
62
69
  };
63
70
 
71
+ /**
72
+ * Full replacement: discard existing items and rebuild from the payload,
73
+ * assigning a stable id to each new item.
74
+ */
64
75
  const applyTextItems = (
65
76
  content: TextContent,
66
77
  items: TextSectionItem[],
@@ -71,26 +82,33 @@ const applyTextItems = (
71
82
  );
72
83
  };
73
84
 
85
+ /**
86
+ * Id-based merge (BP-5603): mirrors the section-level id contract one level
87
+ * deeper.
88
+ * - item with `item_id` matching an existing item → update in place and
89
+ * reposition if `move_before_item_id` is set; unknown id is skipped.
90
+ * - item without `item_id` → add a new item,
91
+ * positioned by `move_before_item_id`.
92
+ * - `removeIds` → delete by id.
93
+ *
94
+ * Existing items are id-backfilled first so legacy sections without ids
95
+ * become referenceable the moment this builder processes them.
96
+ */
74
97
  const mergeTextItems = (
75
98
  content: TextContent,
76
99
  items: TextSectionItem[],
77
- replaceAll: boolean,
100
+ removeIds: string[] | undefined,
78
101
  textStyleOptions: TextStyleOptions | undefined,
79
102
  ): void => {
80
- const hasIndexedItems = items.some((item) => item.item_index !== undefined);
81
-
82
- if (hasIndexedItems) {
83
- for (const item of items) {
84
- const targetIndex = item.item_index;
103
+ ensureItemIds(content.textItems);
104
+ content.textItems = removeItemsByIds(content.textItems, removeIds);
85
105
 
86
- if (
87
- targetIndex === undefined ||
88
- targetIndex < 0 ||
89
- !content.textItems?.[targetIndex]
90
- )
91
- continue;
106
+ for (const item of items) {
107
+ if (item.item_id) {
108
+ const existing = content.textItems?.find((i) => i.id === item.item_id);
92
109
 
93
- const existing = content.textItems[targetIndex];
110
+ // Unknown id — skip rather than guess at a position.
111
+ if (!existing) continue;
94
112
 
95
113
  if (item.text !== undefined) {
96
114
  const { html, wasTiptap } = resolveText(item.text);
@@ -104,51 +122,32 @@ const mergeTextItems = (
104
122
  }
105
123
 
106
124
  if (item.text_style && typeof item.text_style === 'object') {
107
- const mergedStyle = (
108
- item.text_style
109
- ? { ...(existing.textStyle || {}), ...item.text_style }
110
- : existing.textStyle || {}
111
- ) as TextStyleDeclaration;
112
-
113
- existing.textStyle = mergedStyle;
125
+ existing.textStyle = {
126
+ ...(existing.textStyle || {}),
127
+ ...item.text_style,
128
+ } as TextStyleDeclaration;
114
129
  }
115
- }
116
-
117
- return;
118
- }
119
-
120
- const itemsToMerge = items.filter((item) => item.insert_at === undefined);
121
-
122
- const updatedItems = itemsToMerge.map((item, index) => {
123
- const existing = content.textItems?.[index];
124
- const baseStyle = existing?.textStyle || content.textStyle || {};
125
-
126
- const mergedStyle = (
127
- item.text_style ? { ...baseStyle, ...item.text_style } : baseStyle
128
- ) as TextStyleDeclaration;
129
130
 
130
- const resolved = item.text !== undefined ? resolveText(item.text) : null;
131
- const text = resolved ? resolved.html : (existing?.text ?? '');
132
-
133
- return {
134
- text,
135
- textStyle: clearTextStyleTypeIfTiptap(
136
- mergedStyle,
137
- resolved?.wasTiptap ?? false,
131
+ repositionItemByAnchor(
132
+ content.textItems!,
133
+ existing,
134
+ item.move_before_item_id,
135
+ );
136
+ } else {
137
+ const newItem = createTextItem(
138
+ item,
139
+ content.textStyle || {},
138
140
  textStyleOptions,
139
- ),
140
- };
141
- });
141
+ );
142
142
 
143
- if (
144
- !replaceAll &&
145
- content.textItems &&
146
- content.textItems.length > itemsToMerge.length
147
- ) {
148
- updatedItems.push(...content.textItems.slice(itemsToMerge.length));
143
+ content.textItems = content.textItems ?? [];
144
+ repositionItemByAnchor(
145
+ content.textItems,
146
+ newItem,
147
+ item.move_before_item_id,
148
+ );
149
+ }
149
150
  }
150
-
151
- content.textItems = updatedItems;
152
151
  };
153
152
 
154
153
  /**
@@ -176,6 +175,7 @@ const applySingleTextField = (
176
175
  } else {
177
176
  content.textItems = [
178
177
  {
178
+ id: generateItemId(),
179
179
  text: html,
180
180
  textStyle: clearTextStyleTypeIfTiptap(
181
181
  { ...(content.textStyle || {}) },
@@ -236,82 +236,31 @@ export class TextSectionBuilder extends BaseSectionBuilder<TextContent> {
236
236
 
237
237
  const args = this.args as TextSectionArguments;
238
238
 
239
- if (args.items && Array.isArray(args.items)) {
240
- if (this.mode === 'create') {
239
+ if (args._replace_items && args.items && Array.isArray(args.items)) {
240
+ // Full replacement: discard existing items and rebuild from the payload.
241
+ applyTextItems(content, args.items, textStyleOptions);
242
+ } else if (this.mode === 'create') {
243
+ if (args.items && Array.isArray(args.items)) {
241
244
  applyTextItems(content, args.items, textStyleOptions);
242
245
  } else {
243
- mergeTextItems(
244
- content,
245
- args.items,
246
- !!args._replace_items,
247
- textStyleOptions,
248
- );
246
+ applySingleTextField(content, args, textStyleOptions);
249
247
  }
248
+ } else if (
249
+ (args.items && Array.isArray(args.items)) ||
250
+ args._remove_item_ids?.length
251
+ ) {
252
+ // Update: id-based add / edit / move / remove in a single pass.
253
+ mergeTextItems(
254
+ content,
255
+ args.items ?? [],
256
+ args._remove_item_ids,
257
+ textStyleOptions,
258
+ );
250
259
  } else {
260
+ // No items array: apply the single text/text_style shorthand fields.
251
261
  applySingleTextField(content, args, textStyleOptions);
252
262
  }
253
263
 
254
- // Phase 2: Item operations (remove, insert, reorder)
255
- if (args._remove_item_indices?.length && content.textItems) {
256
- const sorted = [...args._remove_item_indices].sort((a, b) => b - a);
257
-
258
- for (const idx of sorted) {
259
- if (idx >= 0 && idx < content.textItems.length) {
260
- content.textItems.splice(idx, 1);
261
- }
262
- }
263
- }
264
-
265
- if (args.items && Array.isArray(args.items)) {
266
- const insertItems = args.items.filter(
267
- (item) => item.insert_at !== undefined && item.item_index === undefined,
268
- );
269
- const sortedInserts = [...insertItems].sort(
270
- (a, b) => (b.insert_at ?? 0) - (a.insert_at ?? 0),
271
- );
272
-
273
- for (const item of sortedInserts) {
274
- const pos = Math.max(
275
- 0,
276
- Math.min(item.insert_at ?? 0, content.textItems?.length ?? 0),
277
- );
278
- const baseStyle = content.textStyle || {};
279
- const textStyle = (
280
- item.text_style
281
- ? { ...baseStyle, ...item.text_style }
282
- : { ...baseStyle }
283
- ) as TextStyleDeclaration;
284
-
285
- const { html, wasTiptap } = resolveText(item.text);
286
- const newItem: TextItem = {
287
- text: html,
288
- textStyle: clearTextStyleTypeIfTiptap(
289
- textStyle,
290
- wasTiptap,
291
- textStyleOptions,
292
- ),
293
- };
294
-
295
- content.textItems = content.textItems ?? [];
296
- content.textItems.splice(pos, 0, newItem);
297
- }
298
- }
299
-
300
- if (args._reorder_items?.length && content.textItems) {
301
- const newOrder = args._reorder_items;
302
- const unique = new Set(newOrder);
303
-
304
- if (unique.size === newOrder.length) {
305
- const reordered = newOrder
306
- .filter((idx) => idx >= 0 && idx < content.textItems!.length)
307
- .map((idx) => content.textItems![idx]);
308
-
309
- if (reordered.length === content.textItems.length) {
310
- content.textItems = reordered;
311
- }
312
- }
313
- }
314
-
315
264
  if (args.background_color !== undefined) {
316
265
  content.backgroundColor = {
317
266
  ...content.backgroundColor,
@@ -37,6 +37,13 @@ function defineFieldMap<C extends Content>(
37
37
  return map;
38
38
  }
39
39
 
40
+ // BP-5687: vertical padding fields shared by all sections that render
41
+ // contentPaddingStyle (see src/brand-page/utils/content-padding.util.ts).
42
+ const PADDING_FIELDS = {
43
+ padding_top: 'topPadding',
44
+ padding_bottom: 'bottomPadding',
45
+ } as const;
46
+
40
47
  const bannerFields = defineFieldMap<BannerSectionContent>({
41
48
  title: 'title',
42
49
  description: 'description',
@@ -45,6 +52,7 @@ const bannerFields = defineFieldMap<BannerSectionContent>({
45
52
  background_image_url: 'imageUrl',
46
53
  background_shape_url: 'imageUrl',
47
54
  display_style: 'displayStyle',
55
+ ...PADDING_FIELDS,
48
56
  });
49
57
 
50
58
  const bookingFields = defineFieldMap<BookingSectionContent>({
@@ -52,6 +60,7 @@ const bookingFields = defineFieldMap<BookingSectionContent>({
52
60
  description: 'description',
53
61
  button_text: 'buttonText',
54
62
  button_url: 'url',
63
+ ...PADDING_FIELDS,
55
64
  });
56
65
 
57
66
  const aboutFields = defineFieldMap<AboutSectionContent>({
@@ -60,12 +69,14 @@ const aboutFields = defineFieldMap<AboutSectionContent>({
60
69
  about_image_url: 'imageUrl',
61
70
  about_shape_url: 'imageUrl',
62
71
  display_style: 'displayStyle',
72
+ ...PADDING_FIELDS,
63
73
  });
64
74
 
65
75
  const testimonialFields = defineFieldMap<TestimonialSectionContent>({
66
76
  title: 'title',
67
77
  display_style: 'displayStyle',
68
78
  // items handled separately
79
+ ...PADDING_FIELDS,
69
80
  });
70
81
 
71
82
  const imagesSectionFields = defineFieldMap<ImagesSectionContent>({
@@ -75,6 +86,7 @@ const imagesSectionFields = defineFieldMap<ImagesSectionContent>({
75
86
  aspect_ratio: 'aspectRatio',
76
87
  are_images_full_width: 'areImagesFullWidth',
77
88
  // items handled separately
89
+ ...PADDING_FIELDS,
78
90
  });
79
91
 
80
92
  const shapesSectionFields = defineFieldMap<ShapesSectionContent>({
@@ -83,11 +95,13 @@ const shapesSectionFields = defineFieldMap<ShapesSectionContent>({
83
95
  display_style: 'displayStyle',
84
96
  size: 'size',
85
97
  // items handled separately
98
+ ...PADDING_FIELDS,
86
99
  });
87
100
 
88
101
  const textFields = defineFieldMap<TextContent>({
89
102
  text: 'text',
90
103
  column_style: 'columnStyle',
104
+ ...PADDING_FIELDS,
91
105
  });
92
106
 
93
107
  const profileFields = defineFieldMap<ProfileContent>({
@@ -106,17 +120,20 @@ const contactFormFields = defineFieldMap<ContactFormContent>({
106
120
  header_image_url: 'imageUrl',
107
121
  display_style: 'displayStyle',
108
122
  layout_style: 'layoutStyle',
123
+ ...PADDING_FIELDS,
109
124
  });
110
125
 
111
126
  const mailingListFields = defineFieldMap<MailingListContent>({
112
127
  button_text: 'buttonText',
113
128
  accepted_fields: 'acceptedFields',
129
+ ...PADDING_FIELDS,
114
130
  });
115
131
 
116
132
  const socialLinksFields = defineFieldMap<SocialLinksContent>({
117
133
  title: 'title',
118
134
  icon_style: 'iconDisplayStyle',
119
135
  // links handled separately
136
+ ...PADDING_FIELDS,
120
137
  });
121
138
 
122
139
  const dividerFields = defineFieldMap<DividerSectionContent>({
@@ -132,6 +149,7 @@ const imageFields = defineFieldMap<ImageContent>({
132
149
  const imageGalleryFields = defineFieldMap<ImageGalleryContent>({
133
150
  is_full_width: 'isFullWidth',
134
151
  // images handled separately
152
+ ...PADDING_FIELDS,
135
153
  });
136
154
 
137
155
  const buttonWithIconFields = defineFieldMap<ButtonContent>({