@designcrowd/library.brand-page 6.2.3 → 6.2.4

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 (112) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Cn0M-eZ1.js → CreatedOnBrandCrowd-de-de-ystrO2LW.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DRLMSDZr.js → CreatedOnBrandCrowd-en-us-BU7hNzTZ.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BglTu73U.js → CreatedOnBrandCrowd-es-es-CbuxneQ3.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Bj6YZ_el.js → CreatedOnBrandCrowd-fr-ca-BuNUGOFr.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Bj6YZ_el.js → CreatedOnBrandCrowd-fr-fr-BuNUGOFr.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DEoOmQ1F.js → CreatedOnBrandCrowd-pt-br-BehL88mc.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DEoOmQ1F.js → CreatedOnBrandCrowd-pt-pt-BehL88mc.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BZ7gTt4Z.js → CreatedOnDcom-de-de-CMxjFZt9.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CD6Km5sH.js → CreatedOnDcom-en-us-2Cf6WBvx.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CNpZHvEl.js → CreatedOnDcom-es-es-CATNnbTq.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-URS-ydJ1.js → CreatedOnDcom-fr-ca-DQtMfB-a.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-D2AVeA3H.js → CreatedOnDcom-fr-fr-C1_FWOQf.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-C32frnFs.js → CreatedOnDcom-pt-br-C0QOCW0L.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-TDYGjRXS.js → CreatedOnDcom-pt-pt-lHD-p6o4.js} +1 -1
  15. package/lib/src/brand-page/models/brand-page-context.model.d.ts +5 -0
  16. package/lib/src/brand-page/models/brand-page-context.model.js +8 -1
  17. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +21 -0
  18. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +21 -0
  19. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +7 -0
  20. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +21 -0
  21. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +22 -1
  22. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +21 -0
  23. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +21 -0
  24. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +21 -0
  25. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +21 -0
  26. package/lib/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.d.ts +10 -0
  27. package/lib/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.js +11 -0
  28. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +14 -4
  29. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.d.ts +9 -2
  30. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.js +20 -0
  31. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +21 -0
  32. package/lib/src/clients/ai-assistant-client.d.ts +1 -0
  33. package/lib/src/clients/ai-assistant-client.js +1 -0
  34. package/lib/src/index.mjs +224 -223
  35. package/lib/src/{lib-Dm9AuOJL.js → lib-CqHUUPUW.js} +21920 -21691
  36. package/lib/src/metadata-builder/builders/global-font-propagation.d.ts +41 -0
  37. package/lib/src/metadata-builder/builders/global-font-propagation.js +146 -0
  38. package/lib/src/metadata-builder/builders/global-styles-builder.js +29 -1
  39. package/lib/src/metadata-builder/builders/header-builder.js +8 -0
  40. package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +13 -0
  41. package/lib/src/metadata-builder/builders/reorder-pages-builder.js +49 -0
  42. package/lib/src/metadata-builder/builders/section-builders/index.d.ts +1 -0
  43. package/lib/src/metadata-builder/builders/section-builders/index.js +1 -0
  44. package/lib/src/metadata-builder/builders/section-builders/support-me-builder.d.ts +8 -0
  45. package/lib/src/metadata-builder/builders/section-builders/support-me-builder.js +25 -0
  46. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +5 -2
  47. package/lib/src/metadata-builder/builders/section-field-maps.js +15 -0
  48. package/lib/src/metadata-builder/builders/section-field-utils.d.ts +6 -1
  49. package/lib/src/metadata-builder/builders/section-field-utils.js +48 -12
  50. package/lib/src/metadata-builder/builders/section-rules/support-me.rules.d.ts +2 -0
  51. package/lib/src/metadata-builder/builders/section-rules/support-me.rules.js +17 -0
  52. package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +9 -0
  53. package/lib/src/metadata-builder/helpers/rte.helpers.js +60 -1
  54. package/lib/src/metadata-builder/metadata-executor.js +3 -1
  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 +1 -0
  58. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +4 -0
  59. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +14 -2
  60. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +1 -1
  61. package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +1 -1
  62. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +21 -0
  63. package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +1 -1
  64. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +1 -1
  65. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +1 -1
  66. package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +1 -1
  67. package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +1 -1
  68. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +21 -0
  69. package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
  70. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +21 -0
  71. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +21 -0
  72. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +28 -0
  73. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +21 -0
  74. package/lib/src/shared/style-settings/button-style-preset.d.ts +12 -0
  75. package/lib/src/shared/style-settings/button-style-preset.js +39 -0
  76. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +21 -0
  77. package/lib/src/shared/style-settings/store/style-settings-store.js +6 -32
  78. package/lib/src/utils/minify-brand-page-metadata.d.ts +9 -0
  79. package/lib/src/utils/minify-brand-page-metadata.js +23 -1
  80. package/package.json +1 -12
  81. package/src/brand-page/models/brand-page-context.model.ts +14 -1
  82. package/src/brand-page-maker/components/ai-website-builder/AiWebsiteBuilder.vue +1 -1
  83. package/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.test.ts +82 -0
  84. package/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.ts +19 -0
  85. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +18 -4
  86. package/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.ts +30 -0
  87. package/src/clients/ai-assistant-client.ts +1 -0
  88. package/src/metadata-builder/builders/global-font-propagation.test.ts +401 -0
  89. package/src/metadata-builder/builders/global-font-propagation.ts +238 -0
  90. package/src/metadata-builder/builders/global-styles-builder.test.ts +107 -0
  91. package/src/metadata-builder/builders/global-styles-builder.ts +38 -1
  92. package/src/metadata-builder/builders/header-builder.test.ts +45 -0
  93. package/src/metadata-builder/builders/header-builder.ts +20 -0
  94. package/src/metadata-builder/builders/reorder-pages-builder.ts +80 -0
  95. package/src/metadata-builder/builders/section-builders/index.ts +1 -0
  96. package/src/metadata-builder/builders/section-builders/support-me-builder.test.ts +177 -0
  97. package/src/metadata-builder/builders/section-builders/support-me-builder.ts +43 -0
  98. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +5 -2
  99. package/src/metadata-builder/builders/section-field-maps.ts +18 -0
  100. package/src/metadata-builder/builders/section-field-utils.test.ts +83 -0
  101. package/src/metadata-builder/builders/section-field-utils.ts +69 -18
  102. package/src/metadata-builder/builders/section-rules/support-me.rules.ts +39 -0
  103. package/src/metadata-builder/helpers/rte.helpers.ts +82 -1
  104. package/src/metadata-builder/metadata-executor.ts +3 -0
  105. package/src/metadata-builder/models.ts +2 -0
  106. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +4 -0
  107. package/src/metadata-builder/parsers/section-tool-arguments.ts +14 -1
  108. package/src/shared/style-settings/button-style-preset.test.ts +67 -0
  109. package/src/shared/style-settings/button-style-preset.ts +56 -0
  110. package/src/shared/style-settings/store/style-settings-store.ts +9 -38
  111. package/src/utils/minify-brand-page-metadata.test.ts +166 -1
  112. package/src/utils/minify-brand-page-metadata.ts +31 -1
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { BrandPageModel } from '../../brand-page/models';
3
+ import type { UpdateGlobalStylesArguments } from '../parsers/ai-toolcall-parser';
4
+ import { BUILDER_TYPES } from '../models';
5
+ import { GlobalStylesBuilder } from './global-styles-builder';
6
+
7
+ /**
8
+ * BP-6082: for a button shape change the AI's update_global_styles carries only
9
+ * `selectedButtonStyleId` (the tool schema hands shape to the preset). The builder
10
+ * used to store the id and leave `buttonStyles.style` untouched, so the site — which
11
+ * renders buttons from `style` — showed no change while the assistant reported
12
+ * rounded corners.
13
+ */
14
+
15
+ const makeModel = () =>
16
+ ({
17
+ styles: {
18
+ buttonStyles: {
19
+ style: {
20
+ color: 'var(--palette-1)',
21
+ boxShadow: '',
22
+ borderColor: 'var(--palette-0)',
23
+ borderStyle: 'solid',
24
+ borderWidth: '2px',
25
+ borderRadius: '0',
26
+ backgroundColor: 'var(--palette-1)',
27
+ },
28
+ iconStyle: { fill: 'var(--palette-custom-pfeg22)' },
29
+ selectedButtonStyleId: 'BUTTON_STYLE_4',
30
+ },
31
+ },
32
+ }) as unknown as BrandPageModel;
33
+
34
+ const run = (args: UpdateGlobalStylesArguments, model: BrandPageModel) =>
35
+ new GlobalStylesBuilder({
36
+ id: 'g1',
37
+ type: BUILDER_TYPES.UPDATE_GLOBAL_STYLES,
38
+ payload: args,
39
+ }).buildAsync(model);
40
+
41
+ describe('GlobalStylesBuilder — button preset shape (BP-6082)', () => {
42
+ it('applies the chosen preset shape to the style the site renders from', async () => {
43
+ const { model } = await run(
44
+ {
45
+ buttonStyles: {
46
+ style: {},
47
+ iconStyle: {},
48
+ selectedButtonStyleId: 'BUTTON_STYLE_5',
49
+ },
50
+ },
51
+ makeModel(),
52
+ );
53
+ const bs = model.styles.buttonStyles;
54
+
55
+ expect(bs.selectedButtonStyleId).toBe('BUTTON_STYLE_5');
56
+ expect(bs.style.borderRadius).toBe('8px');
57
+ expect(bs.style.borderStyle).toBe('solid');
58
+ expect(bs.style.borderWidth).toBe('2px');
59
+ expect(bs.style.boxShadow).toBe('');
60
+ // colours are not the preset's to change
61
+ expect(bs.style.color).toBe('var(--palette-1)');
62
+ expect(bs.style.backgroundColor).toBe('var(--palette-1)');
63
+ expect(bs.iconStyle.fill).toBe('var(--palette-custom-pfeg22)');
64
+ });
65
+
66
+ it('keeps explicit colours from the same call while the preset owns the shape', async () => {
67
+ const { model } = await run(
68
+ {
69
+ buttonStyles: {
70
+ style: { backgroundColor: '#ff0000' },
71
+ selectedButtonStyleId: 'BUTTON_STYLE_5',
72
+ },
73
+ },
74
+ makeModel(),
75
+ );
76
+
77
+ expect(model.styles.buttonStyles.style.backgroundColor).toBe('#ff0000');
78
+ expect(model.styles.buttonStyles.style.borderRadius).toBe('8px');
79
+ });
80
+
81
+ it('stores an unknown id and leaves the shape alone', async () => {
82
+ const { model } = await run(
83
+ { buttonStyles: { selectedButtonStyleId: 'BUTTON_STYLE_999' } },
84
+ makeModel(),
85
+ );
86
+
87
+ expect(model.styles.buttonStyles.selectedButtonStyleId).toBe(
88
+ 'BUTTON_STYLE_999',
89
+ );
90
+ expect(model.styles.buttonStyles.style).toEqual(
91
+ makeModel().styles.buttonStyles.style,
92
+ );
93
+ });
94
+
95
+ it('a style-only call still merges without touching the shape', async () => {
96
+ const { model } = await run(
97
+ { buttonStyles: { style: { color: '#000000' } } },
98
+ makeModel(),
99
+ );
100
+
101
+ expect(model.styles.buttonStyles.style.color).toBe('#000000');
102
+ expect(model.styles.buttonStyles.style.borderRadius).toBe('0');
103
+ expect(model.styles.buttonStyles.selectedButtonStyleId).toBe(
104
+ 'BUTTON_STYLE_4',
105
+ );
106
+ });
107
+ });
@@ -14,6 +14,11 @@ import {
14
14
  GLOBAL_STYLE_CHECKS,
15
15
  TEXT_STYLE_OPTION_KEYS,
16
16
  } from './section-rules/global-styles.rules';
17
+ import {
18
+ collectChangedFontFamilies,
19
+ propagateFontFamiliesToContents,
20
+ } from './global-font-propagation';
21
+ import { buttonStylePresetCss } from '../../shared/style-settings/button-style-preset';
17
22
 
18
23
  export class GlobalStylesBuilder extends BaseMetadataBuilder<UpdateGlobalStylesArguments> {
19
24
  constructor(
@@ -54,6 +59,23 @@ export class GlobalStylesBuilder extends BaseMetadataBuilder<UpdateGlobalStylesA
54
59
 
55
60
  if (args.textStyles) {
56
61
  this.applyTextStyles(model, args.textStyles);
62
+
63
+ // A category font change only reaches preset-bound content by itself;
64
+ // detached section styles and inline RTE font marks would still render
65
+ // the old family. Propagate so the change is actually visible (BP-5780).
66
+ const changedFamilies = collectChangedFontFamilies(args.textStyles);
67
+
68
+ if (Object.keys(changedFamilies).length) {
69
+ try {
70
+ propagateFontFamiliesToContents(model, changedFamilies);
71
+ } catch (error) {
72
+ this.log({
73
+ message:
74
+ 'Global font propagation to existing contents failed — presets still updated',
75
+ error,
76
+ });
77
+ }
78
+ }
57
79
  }
58
80
 
59
81
  if (args.buttonStyles) {
@@ -117,9 +139,24 @@ export class GlobalStylesBuilder extends BaseMetadataBuilder<UpdateGlobalStylesA
117
139
  bs.iconStyle = { ...bs.iconStyle, ...buttonStyles.iconStyle };
118
140
  }
119
141
 
120
- // Merge selectedButtonStyleId
142
+ // Merge selectedButtonStyleId and apply that preset's shape and shadow the way the
143
+ // editor's picker does: the site renders buttons from `style`, so the id alone is
144
+ // invisible (BP-6082). The preset owns the shape keys; colours from `style` stay.
121
145
  if (buttonStyles.selectedButtonStyleId !== undefined) {
122
146
  bs.selectedButtonStyleId = buttonStyles.selectedButtonStyleId;
147
+
148
+ const presetCss = buttonStylePresetCss(
149
+ buttonStyles.selectedButtonStyleId,
150
+ bs.style,
151
+ );
152
+
153
+ if (presetCss) {
154
+ bs.style = { ...bs.style, ...presetCss };
155
+ } else {
156
+ this.log({
157
+ message: `Unknown selectedButtonStyleId ${buttonStyles.selectedButtonStyleId} — id stored, button shape unchanged`,
158
+ });
159
+ }
123
160
  }
124
161
  }
125
162
 
@@ -104,3 +104,48 @@ describe('HeaderBuilder — nav menu font vs global style', () => {
104
104
  ).toBe('buttons');
105
105
  });
106
106
  });
107
+
108
+ describe('HeaderBuilder — nav menu font reaches the hamburger overlay (BP-6079)', () => {
109
+ it('applies link_text_font to the hamburger default and selected button styles', async () => {
110
+ const model = makeModel({ textStyleType: 'buttons' });
111
+ const nav = model.pageHeaderMobile!.navigation as unknown as Record<
112
+ string,
113
+ unknown
114
+ >;
115
+
116
+ nav.hamburgerMenuStyle = {
117
+ defaultButtonStyle: { fontFamily: 'Lato', color: '#111111' },
118
+ selectedButtonStyle: { textStyleType: 'buttons' },
119
+ };
120
+
121
+ const { model: out, errors } = await run(
122
+ { mobile: { link_text_font: 'Oswald' } },
123
+ model,
124
+ );
125
+
126
+ const menu = (
127
+ out.pageHeaderMobile!.navigation as unknown as Record<string, unknown>
128
+ ).hamburgerMenuStyle as Record<string, Record<string, unknown>>;
129
+
130
+ expect(errors ?? []).toEqual([]);
131
+ expect(menu.defaultButtonStyle.fontFamily).toBe('Oswald');
132
+ expect(menu.defaultButtonStyle.color).toBe('#111111');
133
+ expect(menu.selectedButtonStyle.fontFamily).toBe('Oswald');
134
+ expect(menu.selectedButtonStyle.textStyleType).toBeUndefined();
135
+ });
136
+
137
+ it('a header without a hamburger menu style is left alone', async () => {
138
+ const model = makeModel({ textStyleType: 'buttons' });
139
+
140
+ const { model: out, errors } = await run(
141
+ { desktop: { link_text_font: 'Oswald' } },
142
+ model,
143
+ );
144
+
145
+ expect(errors ?? []).toEqual([]);
146
+ expect(
147
+ (out.pageHeaderDesktop!.navigation as unknown as Record<string, unknown>)
148
+ .hamburgerMenuStyle,
149
+ ).toBeUndefined();
150
+ });
151
+ });
@@ -65,6 +65,26 @@ const applyViewportHeaderChanges = (
65
65
  );
66
66
  }
67
67
 
68
+ // The hamburger overlay renders the same nav links from its own styles, so the
69
+ // menu font has to reach them too or the overlay keeps the old family (BP-6079).
70
+ if (
71
+ overrides.link_text_font !== undefined &&
72
+ header.navigation?.hamburgerMenuStyle
73
+ ) {
74
+ const menu = header.navigation.hamburgerMenuStyle;
75
+
76
+ menu.defaultButtonStyle = applyDetachedStyleOverride(
77
+ menu.defaultButtonStyle,
78
+ { fontFamily: overrides.link_text_font },
79
+ textStyleOptions,
80
+ );
81
+ menu.selectedButtonStyle = applyDetachedStyleOverride(
82
+ menu.selectedButtonStyle,
83
+ { fontFamily: overrides.link_text_font },
84
+ textStyleOptions,
85
+ );
86
+ }
87
+
68
88
  if (overrides.link_bg_color !== undefined && header.navigation) {
69
89
  header.navigation.defaultButtonStyle = {
70
90
  ...header.navigation.defaultButtonStyle,
@@ -0,0 +1,80 @@
1
+ import type { BrandPageModel, NavOrder } from '../../brand-page/models';
2
+ import {
3
+ BaseMetadataBuilder,
4
+ type BuilderResult,
5
+ type MetadataBuilderRequest,
6
+ } from '../models';
7
+ import type { Logger } from '../logger';
8
+
9
+ export interface ReorderPagesPayload {
10
+ pageOrder: string[];
11
+ }
12
+
13
+ export class ReorderPagesBuilder extends BaseMetadataBuilder<ReorderPagesPayload> {
14
+ constructor(
15
+ request: MetadataBuilderRequest<ReorderPagesPayload>,
16
+ logger?: Logger,
17
+ ) {
18
+ super(request, logger);
19
+ }
20
+
21
+ async validateAsync(): Promise<{ errors: string[] }> {
22
+ const errors: string[] = [];
23
+ const payload = this.request.payload;
24
+
25
+ if (!payload?.pageOrder?.length) {
26
+ errors.push('page_order is required for reorder_pages');
27
+ }
28
+
29
+ return { errors };
30
+ }
31
+
32
+ async buildAsync(model: BrandPageModel): Promise<BuilderResult> {
33
+ this.log('Build called');
34
+
35
+ const validation = await this.validateAsync();
36
+
37
+ if (validation.errors.length) {
38
+ return { request: this.request, model, errors: validation.errors };
39
+ }
40
+
41
+ const { pageOrder } = this.request.payload!;
42
+
43
+ // Build pageId → routeId lookup from nav.routes
44
+ const pageIdToRouteId = new Map<string, string>();
45
+
46
+ for (const route of model.nav.routes) {
47
+ pageIdToRouteId.set(route.pageId, route.id);
48
+ }
49
+
50
+ // Build new nav.orders from the flat page_order (array of pageIds)
51
+ const newOrders: NavOrder[] = [];
52
+ const seenRouteIds = new Set<string>();
53
+
54
+ for (const pageId of pageOrder) {
55
+ const routeId = pageIdToRouteId.get(pageId);
56
+
57
+ if (!routeId) continue;
58
+
59
+ newOrders.push({ routeId, subRoutes: [] });
60
+ seenRouteIds.add(routeId);
61
+ }
62
+
63
+ // Safety: append any routes not mentioned (don't lose pages)
64
+ for (const route of model.nav.routes) {
65
+ const routeId = pageIdToRouteId.get(route.pageId);
66
+
67
+ if (routeId && !seenRouteIds.has(routeId)) {
68
+ newOrders.push({ routeId, subRoutes: [] });
69
+ }
70
+ }
71
+
72
+ model.nav.orders = newOrders;
73
+
74
+ this.log({
75
+ message: `Reordered pages: ${pageOrder.join(', ')}`,
76
+ });
77
+
78
+ return { request: this.request, model };
79
+ }
80
+ }
@@ -5,6 +5,7 @@ export {
5
5
 
6
6
  export { BannerSectionBuilder } from './banner-section-builder';
7
7
  export { BookingSectionBuilder } from './booking-section-builder';
8
+ export { SupportMeBuilder } from './support-me-builder';
8
9
  export { AboutSectionBuilder } from './about-section-builder';
9
10
  export { ContactFormBuilder } from './contact-form-builder';
10
11
  export { DividerSectionBuilder } from './divider-section-builder';
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type {
3
+ BrandPageModel,
4
+ SupportMeContent,
5
+ } from '../../../brand-page/models';
6
+ import { BUILDER_TYPES } from '../../models';
7
+ import type { SupportMeArguments } from '../../parsers/section-tool-arguments';
8
+ import { SupportMeBuilder } from './support-me-builder';
9
+
10
+ const makeModel = (contents: SupportMeContent[] = []): BrandPageModel =>
11
+ ({
12
+ brandPageType: 'brandSite',
13
+ styles: { textStyles: { textStyleOptions: {} } },
14
+ pages: [{ id: 'page-1', contents }],
15
+ }) as unknown as BrandPageModel;
16
+
17
+ const seedContent = (): SupportMeContent =>
18
+ ({
19
+ id: 'sm-1',
20
+ type: 'support-me',
21
+ title: 'Existing title',
22
+ description: 'Existing description',
23
+ successMessage: 'Existing success',
24
+ displayStyle: 'preview',
25
+ supportMeConfig: {
26
+ paymentOptions: [{ amount: '0', id: 'po-1', name: 'Custom' }],
27
+ isCustomerMessageEnabled: true,
28
+ },
29
+ titleStyle: {},
30
+ headingStyle: {},
31
+ descriptionStyle: {},
32
+ buttonTextStyle: {},
33
+ itemTextStyle: {},
34
+ }) as unknown as SupportMeContent;
35
+
36
+ const create = (args: SupportMeArguments, model: BrandPageModel) =>
37
+ new SupportMeBuilder({
38
+ id: 't-create',
39
+ type: BUILDER_TYPES.CREATE_SUPPORT_ME,
40
+ payload: { sectionType: 'support-me', arguments: args, pageId: 'page-1' },
41
+ }).buildAsync(model);
42
+
43
+ const update = (
44
+ args: SupportMeArguments,
45
+ model: BrandPageModel,
46
+ contentId = 'sm-1',
47
+ ) =>
48
+ new SupportMeBuilder({
49
+ id: 't-update',
50
+ type: BUILDER_TYPES.UPDATE_SUPPORT_ME,
51
+ payload: { sectionType: 'support-me', arguments: args, contentId },
52
+ }).buildAsync(model);
53
+
54
+ describe('SupportMeBuilder — create', () => {
55
+ it('creates a support-me section with factory defaults', async () => {
56
+ const model = makeModel();
57
+
58
+ const { model: out, errors } = await create({}, model);
59
+
60
+ expect(errors ?? []).toEqual([]);
61
+
62
+ const created = out.pages[0].contents[0] as SupportMeContent;
63
+
64
+ expect(created.type).toBe('support-me');
65
+ // display style default comes from the factory
66
+ expect(created.displayStyle).toBe('preview');
67
+ // supportMeConfig (payment options) comes free from the factory, never the AI
68
+ expect(created.supportMeConfig.paymentOptions.length).toBeGreaterThan(0);
69
+ });
70
+
71
+ it('applies AI-provided scalar fields over the defaults', async () => {
72
+ const model = makeModel();
73
+
74
+ const { model: out, errors } = await create(
75
+ {
76
+ title: 'Buy me a coffee',
77
+ description: 'Support my work',
78
+ success_message: 'Thanks!',
79
+ display_style: 'collapsed',
80
+ },
81
+ model,
82
+ );
83
+
84
+ expect(errors ?? []).toEqual([]);
85
+
86
+ const created = out.pages[0].contents[0] as SupportMeContent;
87
+
88
+ expect(created.title).toBe('Buy me a coffee');
89
+ expect(created.description).toBe('Support my work');
90
+ expect(created.successMessage).toBe('Thanks!');
91
+ expect(created.displayStyle).toBe('collapsed');
92
+ });
93
+ });
94
+
95
+ describe('SupportMeBuilder — update', () => {
96
+ it('updates scalar fields on an existing section', async () => {
97
+ const model = makeModel([seedContent()]);
98
+
99
+ const { model: out, errors } = await update(
100
+ {
101
+ title: 'New title',
102
+ success_message: 'New success',
103
+ display_style: 'collapsed',
104
+ },
105
+ model,
106
+ );
107
+
108
+ expect(errors ?? []).toEqual([]);
109
+
110
+ const updated = out.pages[0].contents[0] as SupportMeContent;
111
+
112
+ expect(updated.title).toBe('New title');
113
+ expect(updated.successMessage).toBe('New success');
114
+ expect(updated.displayStyle).toBe('collapsed');
115
+ // untouched fields survive
116
+ expect(updated.description).toBe('Existing description');
117
+ // supportMeConfig is never AI-editable
118
+ expect(updated.supportMeConfig.paymentOptions.length).toBe(1);
119
+ });
120
+
121
+ it('applies the five style fields to their model keys', async () => {
122
+ const model = makeModel([seedContent()]);
123
+
124
+ const { model: out, errors } = await update(
125
+ {
126
+ title_style: { textAlign: 'left' },
127
+ heading_style: { textAlign: 'right' },
128
+ description_style: { textAlign: 'center' },
129
+ item_text_style: { textAlign: 'justify' },
130
+ button_text_style: { color: '#123456' },
131
+ },
132
+ model,
133
+ );
134
+
135
+ expect(errors ?? []).toEqual([]);
136
+
137
+ const updated = out.pages[0].contents[0] as SupportMeContent;
138
+
139
+ expect(updated.titleStyle).toMatchObject({ textAlign: 'left' });
140
+ expect(updated.headingStyle).toMatchObject({ textAlign: 'right' });
141
+ expect(updated.descriptionStyle).toMatchObject({ textAlign: 'center' });
142
+ expect(updated.itemTextStyle).toMatchObject({ textAlign: 'justify' });
143
+ expect(updated.buttonTextStyle).toMatchObject({ color: '#123456' });
144
+ });
145
+ });
146
+
147
+ describe('SupportMeBuilder — validation', () => {
148
+ it('rejects an invalid display_style enum value', async () => {
149
+ const model = makeModel([seedContent()]);
150
+
151
+ const { errors } = await update({ display_style: 'fancy' as never }, model);
152
+
153
+ expect(errors?.length).toBeGreaterThan(0);
154
+ expect(errors!.join(' ')).toContain('display_style');
155
+ });
156
+
157
+ it('rejects an over-length title', async () => {
158
+ const model = makeModel([seedContent()]);
159
+
160
+ const { errors } = await update({ title: 'x'.repeat(201) }, model);
161
+
162
+ expect(errors?.length).toBeGreaterThan(0);
163
+ expect(errors!.join(' ')).toContain('title');
164
+ });
165
+
166
+ it('rejects an over-length success_message', async () => {
167
+ const model = makeModel([seedContent()]);
168
+
169
+ const { errors } = await update(
170
+ { success_message: 'x'.repeat(1001) },
171
+ model,
172
+ );
173
+
174
+ expect(errors?.length).toBeGreaterThan(0);
175
+ expect(errors!.join(' ')).toContain('success_message');
176
+ });
177
+ });
@@ -0,0 +1,43 @@
1
+ import type {
2
+ BrandPageModel,
3
+ SupportMeContent,
4
+ } from '../../../brand-page/models';
5
+ import type { BuilderResult } from '../../models';
6
+ import { applyScalarFields, applyStyleFields } from '../section-field-utils';
7
+ import { BaseSectionBuilder } from './base-section-builder';
8
+ import { validateSupportMe } from '../section-rules/support-me.rules';
9
+
10
+ export class SupportMeBuilder extends BaseSectionBuilder<SupportMeContent> {
11
+ protected get sectionValidate() {
12
+ return validateSupportMe;
13
+ }
14
+
15
+ async buildAsync(model: BrandPageModel): Promise<BuilderResult> {
16
+ this.log('Build called');
17
+
18
+ const validation = await this.validateAsync();
19
+
20
+ if (validation.errors.length) {
21
+ return { request: this.request, model, errors: validation.errors };
22
+ }
23
+
24
+ const resolved = this.resolveAndRepositionContent(model);
25
+
26
+ if ('error' in resolved) return resolved.error;
27
+
28
+ const content = resolved.content;
29
+
30
+ // title/description/success_message are plain-text fields (no RTE support),
31
+ // so scalar application covers all content fields. supportMeConfig (payment
32
+ // options) is intentionally not AI-editable — it comes from the factory.
33
+ applyScalarFields(content, this.args, this.sectionType);
34
+ applyStyleFields(
35
+ content,
36
+ this.args,
37
+ this.sectionType,
38
+ this.getGlobalTextStyles(model),
39
+ );
40
+
41
+ return { request: this.request, model };
42
+ }
43
+ }
@@ -246,13 +246,16 @@ export class TextSectionBuilder extends BaseSectionBuilder<TextContent> {
246
246
  // Full replacement: discard existing items and rebuild from the payload.
247
247
  applyTextItems(content, args.items, textStyleOptions);
248
248
  } else if (this.mode === 'create') {
249
- if (args.items && Array.isArray(args.items)) {
249
+ // An EMPTY items array must not shadow the single text field — the AI emits
250
+ // `items: []` alongside `text` and the section rendered blank (QA 2026-07-05,
251
+ // T06: items=[] set textItems=[] and `text` was silently discarded).
252
+ if (args.items && Array.isArray(args.items) && args.items.length > 0) {
250
253
  applyTextItems(content, args.items, textStyleOptions);
251
254
  } else {
252
255
  applySingleTextField(content, args, textStyleOptions);
253
256
  }
254
257
  } else if (
255
- (args.items && Array.isArray(args.items)) ||
258
+ (args.items && Array.isArray(args.items) && args.items.length > 0) ||
256
259
  args._remove_item_ids?.length
257
260
  ) {
258
261
  // Update: id-based add / edit / move / remove in a single pass.
@@ -23,6 +23,7 @@ import type {
23
23
  ImageContent,
24
24
  ButtonContent,
25
25
  ImageGalleryContent,
26
+ SupportMeContent,
26
27
  } from '../../brand-page/models';
27
28
 
28
29
  export type SectionFieldMap = Record<string, string>;
@@ -175,9 +176,17 @@ const socialButtonFields = defineFieldMap<ButtonContent>({
175
176
  url: 'url',
176
177
  });
177
178
 
179
+ const supportMeFields = defineFieldMap<SupportMeContent>({
180
+ title: 'title',
181
+ description: 'description',
182
+ success_message: 'successMessage',
183
+ display_style: 'displayStyle',
184
+ });
185
+
178
186
  export const SECTION_FIELD_MAPS: Record<string, SectionFieldMap> = {
179
187
  'banner-section': bannerFields,
180
188
  'booking-section': bookingFields,
189
+ 'support-me': supportMeFields,
181
190
  'about-section': aboutFields,
182
191
  'testimonial-section': testimonialFields,
183
192
  'images-section': imagesSectionFields,
@@ -265,6 +274,14 @@ const mailingListStyleFields = defineFieldMap<MailingListContent>({
265
274
  button_text_style: 'buttonTextStyle',
266
275
  });
267
276
 
277
+ const supportMeStyleFields = defineFieldMap<SupportMeContent>({
278
+ title_style: 'titleStyle',
279
+ heading_style: 'headingStyle',
280
+ description_style: 'descriptionStyle',
281
+ item_text_style: 'itemTextStyle',
282
+ button_text_style: 'buttonTextStyle',
283
+ });
284
+
268
285
  const socialLinksStyleFields = defineFieldMap<SocialLinksContent>({
269
286
  icon_fill_style: 'iconFillStyles',
270
287
  icon_background_style: 'iconBackgroundStyles',
@@ -273,6 +290,7 @@ const socialLinksStyleFields = defineFieldMap<SocialLinksContent>({
273
290
  export const STYLE_FIELD_MAPS: Record<string, SectionFieldMap> = {
274
291
  'banner-section': bannerStyleFields,
275
292
  'booking-section': bookingStyleFields,
293
+ 'support-me': supportMeStyleFields,
276
294
  'about-section': aboutStyleFields,
277
295
  text: textStyleFields,
278
296
  'testimonial-section': testimonialStyleFields,