@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
@@ -22,6 +22,8 @@ import {
22
22
  type AddPageArguments,
23
23
  type RemovePageArguments,
24
24
  type RenamePageArguments,
25
+ type MovePageArguments,
26
+ type SetPageVisibilityArguments,
25
27
  type UpdateFooterArguments,
26
28
  type UpdateGlobalStylesArguments,
27
29
  type UpdateHeaderArguments,
@@ -101,7 +103,7 @@ export const executeToolCallsAsync = async (
101
103
  type: BUILDER_TYPES.MOVE_SECTION as BuilderType,
102
104
  payload: {
103
105
  contentId: arg.content_id,
104
- position: arg.position,
106
+ moveBeforeContentId: arg.move_before_content_id,
105
107
  targetPageId: arg.target_page_id,
106
108
  },
107
109
  };
@@ -145,6 +147,34 @@ export const executeToolCallsAsync = async (
145
147
  };
146
148
  }
147
149
 
150
+ case AI_ASSISTANT_TOOL_NAMES.MOVE_PAGE: {
151
+ const arg = toolcall.arguments as unknown as MovePageArguments;
152
+
153
+ return {
154
+ id: toolcall.toolId,
155
+ type: BUILDER_TYPES.MOVE_PAGE as BuilderType,
156
+ payload: {
157
+ pageId: arg.page_id,
158
+ parentPageId: arg.parent_page_id,
159
+ moveBeforePageId: arg.move_before_page_id,
160
+ },
161
+ };
162
+ }
163
+
164
+ case AI_ASSISTANT_TOOL_NAMES.SET_PAGE_VISIBILITY: {
165
+ const arg =
166
+ toolcall.arguments as unknown as SetPageVisibilityArguments;
167
+
168
+ return {
169
+ id: toolcall.toolId,
170
+ type: BUILDER_TYPES.SET_PAGE_VISIBILITY as BuilderType,
171
+ payload: {
172
+ pageId: arg.page_id,
173
+ isHidden: arg.is_hidden,
174
+ },
175
+ };
176
+ }
177
+
148
178
  case AI_ASSISTANT_TOOL_NAMES.UPDATE_FOOTER: {
149
179
  const arg = toolcall.arguments as unknown as UpdateFooterArguments;
150
180
 
@@ -14,6 +14,10 @@ import { FooterBuilder } from './footer-builder';
14
14
  * The fix routes copyright style writes through `applyDetachedStyleOverride`,
15
15
  * which resolves the preset in and clears `textStyleType` so the per-element
16
16
  * override sticks.
17
+ *
18
+ * BP-5603: social link mutations converted from index-based to id-based
19
+ * (item_id / move_before_item_id / _remove_social_link_ids), mirroring the
20
+ * section-level move_before_content_id contract one level deeper.
17
21
  */
18
22
 
19
23
  const textStyleOptions = {
@@ -42,6 +46,37 @@ const makeModel = (copyrightStyle: Record<string, unknown>): BrandPageModel =>
42
46
  pageFooterMobile: { copyrightStyle: { ...copyrightStyle } },
43
47
  }) as unknown as BrandPageModel;
44
48
 
49
+ const makeModelWithLinks = (
50
+ links: { id?: string; imageUrl: string; url: string }[],
51
+ ): BrandPageModel =>
52
+ ({
53
+ styles: { textStyles: { textStyleOptions } },
54
+ pageFooterDesktop: {
55
+ copyrightStyle: {},
56
+ socialLinks: links.map((l) => ({
57
+ id: l.id,
58
+ imageUrl: l.imageUrl,
59
+ url: l.url,
60
+ imageTitle: '',
61
+ width: 0,
62
+ height: 0,
63
+ imageLink: '',
64
+ })),
65
+ },
66
+ pageFooterMobile: {
67
+ copyrightStyle: {},
68
+ socialLinks: links.map((l) => ({
69
+ id: l.id,
70
+ imageUrl: l.imageUrl,
71
+ url: l.url,
72
+ imageTitle: '',
73
+ width: 0,
74
+ height: 0,
75
+ imageLink: '',
76
+ })),
77
+ },
78
+ }) as unknown as BrandPageModel;
79
+
45
80
  const run = (args: UpdateFooterArguments, model: BrandPageModel) =>
46
81
  new FooterBuilder({
47
82
  id: 'f1',
@@ -49,6 +84,10 @@ const run = (args: UpdateFooterArguments, model: BrandPageModel) =>
49
84
  payload: args,
50
85
  }).buildAsync(model);
51
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Copyright font (BP-5420)
89
+ // ---------------------------------------------------------------------------
90
+
52
91
  describe('FooterBuilder — copyright font vs global style', () => {
53
92
  it('detaches from the global preset: applies the font, clears textStyleType, retains preset values', async () => {
54
93
  const model = makeModel({
@@ -137,9 +176,10 @@ describe('FooterBuilder — copyright font vs global style', () => {
137
176
  });
138
177
  });
139
178
 
140
- type Link = { imageUrl: string; url: string };
179
+ type Link = { id?: string; imageUrl: string; url: string };
141
180
 
142
- const link = (imageUrl: string): Link => ({
181
+ const link = (imageUrl: string, id?: string): Link => ({
182
+ ...(id && { id }),
143
183
  imageUrl,
144
184
  url: `https://${imageUrl}.com`,
145
185
  });
@@ -173,18 +213,15 @@ const socialIcons = (footer: { socialLinks?: Link[] } | undefined) =>
173
213
  describe('FooterBuilder — social link removal (shared-array regression)', () => {
174
214
  // Reproduces the bug: a single requested removal index dropped TWO links
175
215
  // because both viewports shared one array and the builder spliced it twice.
176
- it('removes exactly one link per requested index even when viewports share the array', async () => {
216
+ it('removes exactly one link per requested id even when viewports share the array', async () => {
177
217
  const model = makeSharedSocialModel([
178
- link('facebook'),
179
- link('instagram'),
180
- link('x'),
181
- link('youtube'),
218
+ link('facebook', 'fb'),
219
+ link('instagram', 'ig'),
220
+ link('x', 'x'),
221
+ link('youtube', 'yt'),
182
222
  ]);
183
223
 
184
- const { model: out } = await run(
185
- { _remove_social_link_indices: [2] },
186
- model,
187
- );
224
+ const { model: out } = await run({ _remove_social_link_ids: ['x'] }, model);
188
225
 
189
226
  expect(socialIcons(out.pageFooterDesktop)).toEqual([
190
227
  'facebook',
@@ -200,13 +237,13 @@ describe('FooterBuilder — social link removal (shared-array regression)', () =
200
237
 
201
238
  it('un-shares the viewport arrays so later in-place edits cannot cross-corrupt', async () => {
202
239
  const model = makeSharedSocialModel([
203
- link('facebook'),
204
- link('instagram'),
205
- link('x'),
240
+ link('facebook', 'fb'),
241
+ link('instagram', 'ig'),
242
+ link('x', 'x'),
206
243
  ]);
207
244
 
208
245
  const { model: out } = await run(
209
- { _remove_social_link_indices: [0] },
246
+ { _remove_social_link_ids: ['fb'] },
210
247
  model,
211
248
  );
212
249
 
@@ -215,16 +252,16 @@ describe('FooterBuilder — social link removal (shared-array regression)', () =
215
252
  );
216
253
  });
217
254
 
218
- it('handles multiple removal indices on a shared array (descending splice)', async () => {
255
+ it('handles multiple removal ids on a shared array (order-independent)', async () => {
219
256
  const model = makeSharedSocialModel([
220
- link('facebook'),
221
- link('instagram'),
222
- link('x'),
223
- link('youtube'),
257
+ link('facebook', 'fb'),
258
+ link('instagram', 'ig'),
259
+ link('x', 'x'),
260
+ link('youtube', 'yt'),
224
261
  ]);
225
262
 
226
263
  const { model: out } = await run(
227
- { _remove_social_link_indices: [0, 2] },
264
+ { _remove_social_link_ids: ['fb', 'x'] },
228
265
  model,
229
266
  );
230
267
 
@@ -235,15 +272,15 @@ describe('FooterBuilder — social link removal (shared-array regression)', () =
235
272
  expect(socialIcons(out.pageFooterMobile)).toEqual(['instagram', 'youtube']);
236
273
  });
237
274
 
238
- it('removes one link per index for independent viewport arrays (no regression)', async () => {
275
+ it('removes one link per id for independent viewport arrays (no regression)', async () => {
239
276
  const model = makeIndependentSocialModel([
240
- link('facebook'),
241
- link('instagram'),
242
- link('x'),
277
+ link('facebook', 'fb'),
278
+ link('instagram', 'ig'),
279
+ link('x', 'x'),
243
280
  ]);
244
281
 
245
282
  const { model: out } = await run(
246
- { _remove_social_link_indices: [1] },
283
+ { _remove_social_link_ids: ['ig'] },
247
284
  model,
248
285
  );
249
286
 
@@ -252,11 +289,16 @@ describe('FooterBuilder — social link removal (shared-array regression)', () =
252
289
  });
253
290
 
254
291
  it('inserts exactly one link on a shared array (symmetric add guard)', async () => {
255
- const model = makeSharedSocialModel([link('facebook'), link('instagram')]);
292
+ const model = makeSharedSocialModel([
293
+ link('facebook', 'fb'),
294
+ link('instagram', 'ig'),
295
+ ]);
256
296
 
257
297
  const { model: out } = await run(
258
298
  {
259
- social_links: [{ insert_at: 1, social_link_icon: 'x', url: 'x.com' }],
299
+ social_links: [
300
+ { social_link_icon: 'x', url: 'x.com', move_before_item_id: 'ig' },
301
+ ],
260
302
  },
261
303
  model,
262
304
  );
@@ -273,3 +315,268 @@ describe('FooterBuilder — social link removal (shared-array regression)', () =
273
315
  ]);
274
316
  });
275
317
  });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Social links — id-based mutations (BP-5603)
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('FooterBuilder — social links id-based mutations', () => {
324
+ it('backfills ids on legacy links when a social mutation runs', async () => {
325
+ // Links without ids (legacy persisted data). The executor recomputes social
326
+ // links only when a social-link mutation is present (a copyright-only update
327
+ // must not rewrite them); read-time backfill lives in ensureBrandPageItemIds.
328
+ const model = makeModelWithLinks([
329
+ { imageUrl: 'facebook', url: 'https://fb.com' },
330
+ { imageUrl: 'instagram', url: 'https://ig.com' },
331
+ ]);
332
+
333
+ const { model: out } = await run(
334
+ { social_links: [{ social_link_icon: 'x', url: 'https://x.com' }] },
335
+ model,
336
+ );
337
+
338
+ const links = out.pageFooterDesktop!.socialLinks!;
339
+
340
+ // Pre-existing legacy links are backfilled with distinct stable ids.
341
+ expect(links[0].id).toBeTruthy();
342
+ expect(links[1].id).toBeTruthy();
343
+ expect(links[0].id).not.toBe(links[1].id);
344
+ });
345
+
346
+ it('updates an existing link in place by item_id', async () => {
347
+ const model = makeModelWithLinks([
348
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
349
+ { id: 'link-2', imageUrl: 'instagram', url: 'https://ig.com' },
350
+ ]);
351
+
352
+ const { model: out } = await run(
353
+ {
354
+ social_links: [
355
+ { item_id: 'link-1', url: 'https://facebook.com/mybrand' },
356
+ ],
357
+ },
358
+ model,
359
+ );
360
+
361
+ const links = out.pageFooterDesktop!.socialLinks;
362
+
363
+ expect(links).toHaveLength(2);
364
+ expect(links[0].url).toBe('https://facebook.com/mybrand');
365
+ expect(links[0].id).toBe('link-1');
366
+ // untouched link unchanged
367
+ expect(links[1].id).toBe('link-2');
368
+ expect(links[1].url).toBe('https://ig.com');
369
+ });
370
+
371
+ it('updates social_link_icon on an existing link', async () => {
372
+ const model = makeModelWithLinks([
373
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
374
+ ]);
375
+
376
+ const { model: out } = await run(
377
+ {
378
+ social_links: [{ item_id: 'link-1', social_link_icon: 'facebook-v2' }],
379
+ },
380
+ model,
381
+ );
382
+
383
+ expect(out.pageFooterDesktop!.socialLinks[0].imageUrl).toBe('facebook-v2');
384
+ });
385
+
386
+ it('skips unknown item_id silently', async () => {
387
+ const model = makeModelWithLinks([
388
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
389
+ ]);
390
+
391
+ const { model: out } = await run(
392
+ {
393
+ social_links: [{ item_id: 'no-such-id', url: 'https://nowhere.com' }],
394
+ },
395
+ model,
396
+ );
397
+
398
+ const links = out.pageFooterDesktop!.socialLinks;
399
+
400
+ expect(links).toHaveLength(1);
401
+ expect(links[0].url).toBe('https://fb.com');
402
+ });
403
+
404
+ it('inserts a new link (no item_id) appended by default', async () => {
405
+ const model = makeModelWithLinks([
406
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
407
+ ]);
408
+
409
+ const { model: out } = await run(
410
+ {
411
+ social_links: [
412
+ { social_link_icon: 'instagram', url: 'https://ig.com' },
413
+ ],
414
+ },
415
+ model,
416
+ );
417
+
418
+ const links = out.pageFooterDesktop!.socialLinks;
419
+
420
+ expect(links).toHaveLength(2);
421
+ expect(links[1].imageUrl).toBe('instagram');
422
+ expect(links[1].id).toBeTruthy();
423
+ });
424
+
425
+ it('inserts a new link before an anchor via move_before_item_id', async () => {
426
+ const model = makeModelWithLinks([
427
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
428
+ { id: 'link-2', imageUrl: 'instagram', url: 'https://ig.com' },
429
+ ]);
430
+
431
+ const { model: out } = await run(
432
+ {
433
+ social_links: [
434
+ {
435
+ social_link_icon: 'x',
436
+ url: 'https://x.com',
437
+ move_before_item_id: 'link-2',
438
+ },
439
+ ],
440
+ },
441
+ model,
442
+ );
443
+
444
+ const links = out.pageFooterDesktop!.socialLinks;
445
+
446
+ expect(links).toHaveLength(3);
447
+ expect(links[0].id).toBe('link-1');
448
+ expect(links[1].imageUrl).toBe('x');
449
+ expect(links[2].id).toBe('link-2');
450
+ });
451
+
452
+ it('inserts a new link at end via move_before_item_id "__end__"', async () => {
453
+ const model = makeModelWithLinks([
454
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
455
+ { id: 'link-2', imageUrl: 'instagram', url: 'https://ig.com' },
456
+ ]);
457
+
458
+ const { model: out } = await run(
459
+ {
460
+ social_links: [
461
+ {
462
+ social_link_icon: 'x',
463
+ url: 'https://x.com',
464
+ move_before_item_id: '__end__',
465
+ },
466
+ ],
467
+ },
468
+ model,
469
+ );
470
+
471
+ const links = out.pageFooterDesktop!.socialLinks;
472
+
473
+ expect(links).toHaveLength(3);
474
+ expect(links[2].imageUrl).toBe('x');
475
+ });
476
+
477
+ it('moves an existing link before an anchor via move_before_item_id', async () => {
478
+ const model = makeModelWithLinks([
479
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
480
+ { id: 'link-2', imageUrl: 'instagram', url: 'https://ig.com' },
481
+ { id: 'link-3', imageUrl: 'x', url: 'https://x.com' },
482
+ ]);
483
+
484
+ const { model: out } = await run(
485
+ {
486
+ social_links: [{ item_id: 'link-3', move_before_item_id: 'link-1' }],
487
+ },
488
+ model,
489
+ );
490
+
491
+ const links = out.pageFooterDesktop!.socialLinks;
492
+
493
+ expect(links.map((l) => l.id)).toEqual(['link-3', 'link-1', 'link-2']);
494
+ });
495
+
496
+ it('removes links by id', async () => {
497
+ const model = makeModelWithLinks([
498
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
499
+ { id: 'link-2', imageUrl: 'instagram', url: 'https://ig.com' },
500
+ { id: 'link-3', imageUrl: 'x', url: 'https://x.com' },
501
+ ]);
502
+
503
+ const { model: out } = await run(
504
+ { _remove_social_link_ids: ['link-1', 'link-3'] },
505
+ model,
506
+ );
507
+
508
+ const links = out.pageFooterDesktop!.socialLinks;
509
+
510
+ expect(links).toHaveLength(1);
511
+ expect(links[0].id).toBe('link-2');
512
+ });
513
+
514
+ it('_replace_social_links rebuilds the entire list and assigns fresh ids', async () => {
515
+ const model = makeModelWithLinks([
516
+ { id: 'old-1', imageUrl: 'facebook', url: 'https://fb.com' },
517
+ ]);
518
+
519
+ const { model: out } = await run(
520
+ {
521
+ _replace_social_links: true,
522
+ social_links: [
523
+ { social_link_icon: 'instagram', url: 'https://ig.com' },
524
+ { social_link_icon: 'x', url: 'https://x.com' },
525
+ ],
526
+ },
527
+ model,
528
+ );
529
+
530
+ const links = out.pageFooterDesktop!.socialLinks;
531
+
532
+ expect(links).toHaveLength(2);
533
+ expect(links[0].imageUrl).toBe('instagram');
534
+ expect(links[1].imageUrl).toBe('x');
535
+ // fresh ids assigned, not the old one
536
+ expect(links[0].id).toBeTruthy();
537
+ expect(links[0].id).not.toBe('old-1');
538
+ });
539
+
540
+ it('_replace_social_links with empty social_links clears all', async () => {
541
+ const model = makeModelWithLinks([
542
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
543
+ ]);
544
+
545
+ const { model: out } = await run({ _replace_social_links: true }, model);
546
+
547
+ expect(out.pageFooterDesktop!.socialLinks).toHaveLength(0);
548
+ });
549
+
550
+ it('normalises mailto: and tel: prefixes on new links', async () => {
551
+ const model = makeModelWithLinks([]);
552
+
553
+ const { model: out } = await run(
554
+ {
555
+ social_links: [
556
+ { social_link_icon: 'email', url: 'mailto:me@example.com' },
557
+ { social_link_icon: 'phone', url: 'tel:+1234567890' },
558
+ ],
559
+ },
560
+ model,
561
+ );
562
+
563
+ const links = out.pageFooterDesktop!.socialLinks;
564
+
565
+ expect(links[0].url).toBe('me@example.com');
566
+ expect(links[1].url).toBe('+1234567890');
567
+ });
568
+
569
+ it('applies mutations to both desktop and mobile footers', async () => {
570
+ const model = makeModelWithLinks([
571
+ { id: 'link-1', imageUrl: 'facebook', url: 'https://fb.com' },
572
+ ]);
573
+
574
+ const { model: out } = await run(
575
+ { _remove_social_link_ids: ['link-1'] },
576
+ model,
577
+ );
578
+
579
+ expect(out.pageFooterDesktop!.socialLinks).toHaveLength(0);
580
+ expect(out.pageFooterMobile!.socialLinks).toHaveLength(0);
581
+ });
582
+ });
@@ -10,7 +10,6 @@ import {
10
10
  import type {
11
11
  UpdateFooterArguments,
12
12
  ViewportFooterOverrides,
13
- FooterSocialLinkItem,
14
13
  } from '../parsers/ai-toolcall-parser';
15
14
  import type { Logger } from '../logger';
16
15
  import type {
@@ -22,6 +21,11 @@ import {
22
21
  applyDetachedStyleOverride,
23
22
  normalizeStyleValue,
24
23
  } from './section-field-utils';
24
+ import {
25
+ generateItemId,
26
+ removeItemsByIds,
27
+ repositionItemByAnchor,
28
+ } from './item-id-utils';
25
29
 
26
30
  type FooterSocialLink = NonNullable<
27
31
  AbstractPageFooterStyles['socialLinks']
@@ -40,6 +44,7 @@ const makeSocialLink = (
40
44
  url: string | undefined,
41
45
  ): FooterSocialLink =>
42
46
  ({
47
+ id: generateItemId(),
43
48
  imageUrl: icon || '',
44
49
  url: normaliseSocialUrl(url || ''),
45
50
  imageTitle: '',
@@ -50,8 +55,7 @@ const makeSocialLink = (
50
55
 
51
56
  const hasSocialLinkMutation = (args: UpdateFooterArguments): boolean =>
52
57
  args._replace_social_links === true ||
53
- (args._remove_social_link_indices?.length ?? 0) > 0 ||
54
- (args._reorder_social_links?.length ?? 0) > 0 ||
58
+ (args._remove_social_link_ids?.length ?? 0) > 0 ||
55
59
  (args.social_links?.length ?? 0) > 0;
56
60
 
57
61
  /**
@@ -80,62 +84,41 @@ const computeSocialLinks = (
80
84
  );
81
85
  }
82
86
 
83
- const links: FooterSocialLink[] = [...(current ?? [])];
84
-
85
- // item_index-based updates for existing links — replace the item reference
86
- // (never mutate the original object, which may be shared across viewports).
87
- for (const link of args.social_links ?? []) {
88
- if (link.item_index === undefined) continue;
89
-
90
- const existing = links[link.item_index];
91
-
92
- if (!existing) continue;
93
-
94
- links[link.item_index] = {
95
- ...existing,
96
- ...(link.social_link_icon !== undefined
97
- ? { imageUrl: link.social_link_icon }
98
- : {}),
99
- ...(link.url !== undefined ? { url: normaliseSocialUrl(link.url) } : {}),
100
- };
101
- }
102
-
103
- // Remove by index (descending order to preserve indices during splice).
104
- if (args._remove_social_link_indices?.length) {
105
- const sorted = [...args._remove_social_link_indices].sort((a, b) => b - a);
87
+ // Work on fresh copies so `current` (which may be shared across viewports)
88
+ // is never mutated. Backfill ids on the copies so legacy links become
89
+ // referenceable by the id-based contract (BP-5603).
90
+ let links: FooterSocialLink[] = (current ?? []).map((l) => ({
91
+ ...l,
92
+ id: l.id || generateItemId(),
93
+ }));
106
94
 
107
- for (const idx of sorted) {
108
- if (idx >= 0 && idx < links.length) links.splice(idx, 1);
109
- }
110
- }
111
-
112
- // Insert new links (descending insert_at order to preserve positions).
113
- const insertLinks = (args.social_links ?? []).filter(
114
- (l): l is FooterSocialLinkItem & { insert_at: number } =>
115
- l.insert_at !== undefined && l.item_index === undefined,
116
- );
117
- const sortedInserts = [...insertLinks].sort(
118
- (a, b) => b.insert_at - a.insert_at,
119
- );
95
+ // Remove by id order-independent, no index shifting.
96
+ links = removeItemsByIds(links, args._remove_social_link_ids);
120
97
 
121
- for (const link of sortedInserts) {
122
- const pos = Math.max(0, Math.min(link.insert_at, links.length));
123
-
124
- links.splice(pos, 0, makeSocialLink(link.social_link_icon, link.url));
125
- }
126
-
127
- // Reorder (requires ALL indices partial reorder is not supported).
128
- if (args._reorder_social_links?.length) {
129
- const newOrder = args._reorder_social_links;
130
- const unique = new Set(newOrder);
131
-
132
- if (
133
- unique.size === newOrder.length &&
134
- newOrder.every((idx) => idx >= 0 && idx < links.length)
135
- ) {
136
- const reordered = newOrder.map((idx) => links[idx]);
137
-
138
- if (reordered.length === links.length) return reordered;
98
+ // Update existing links (item_id present) or insert new ones (no item_id);
99
+ // position/move via move_before_item_id ("__end__" or a missing anchor => end).
100
+ for (const link of args.social_links ?? []) {
101
+ if (link.item_id) {
102
+ const index = links.findIndex((l) => l.id === link.item_id);
103
+
104
+ // Unknown id skip rather than guess at a position.
105
+ if (index === -1) continue;
106
+
107
+ links[index] = {
108
+ ...links[index],
109
+ ...(link.social_link_icon !== undefined
110
+ ? { imageUrl: link.social_link_icon }
111
+ : {}),
112
+ ...(link.url !== undefined
113
+ ? { url: normaliseSocialUrl(link.url) }
114
+ : {}),
115
+ };
116
+
117
+ repositionItemByAnchor(links, links[index], link.move_before_item_id);
118
+ } else {
119
+ const newLink = makeSocialLink(link.social_link_icon, link.url);
120
+
121
+ repositionItemByAnchor(links, newLink, link.move_before_item_id);
139
122
  }
140
123
  }
141
124